From da055aeb3fe75bb43816a8f04642587017a16b13 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 23 Nov 2020 19:26:29 +0100 Subject: [PATCH 001/123] Resurrected original operations code Reverted operations-related changes from https://github.com/json-api-dotnet/JsonApiDotNetCore/pull/571 --- .../Controllers/OperationsController.cs | 14 + .../OperationsExample.csproj | 30 ++ src/Examples/OperationsExample/Program.cs | 15 + .../Properties/launchSettings.json | 27 ++ src/Examples/OperationsExample/Startup.cs | 55 ++++ .../OperationsExample/appsettings.json | 13 + .../Configuration/IJsonApiOptions.cs | 2 + .../JsonApiApplicationBuilder.cs | 25 ++ .../Configuration/JsonApiOptions.cs | 11 + .../JsonApiOperationsController.cs | 60 ++++ .../Middleware/IJsonApiRequest.cs | 2 + .../Middleware/JsonApiMiddleware.cs | 8 + .../Middleware/JsonApiRequest.cs | 3 + .../Models/Operations/Operation.cs | 82 +++++ .../Models/Operations/OperationCode.cs | 14 + .../Models/Operations/OperationsDocument.cs | 17 + .../Models/Operations/Params.cs | 13 + .../Models/Operations/ResourceReference.cs | 10 + .../Serialization/IOperationsDeserializer.cs | 11 + .../Serialization/JsonApiReader.cs | 9 + .../Objects/ResourceIdentifierObject.cs | 9 +- .../Serialization/OperationsDeserializer.cs | 299 ++++++++++++++++++ .../Services/Operations/IOpProcessor.cs | 10 + .../Operations/OperationProcessorResolver.cs | 115 +++++++ .../Operations/OperationsProcessor.cs | 165 ++++++++++ .../Processors/CreateOpProcessor.cs | 80 +++++ .../Operations/Processors/GetOpProcessor.cs | 172 ++++++++++ .../Processors/RemoveOpProcessor.cs | 63 ++++ .../Processors/UpdateOpProcessor.cs | 73 +++++ test/OperationsExampleTests/Add/AddTests.cs | 294 +++++++++++++++++ .../Factories/ArticleFactory.cs | 25 ++ .../Factories/AuthorFactory.cs | 25 ++ test/OperationsExampleTests/Fixture.cs | 60 ++++ .../Get/GetByIdTests.cs | 78 +++++ .../Get/GetRelationshipTests.cs | 87 +++++ test/OperationsExampleTests/Get/GetTests.cs | 73 +++++ .../OperationsExampleTests.csproj | 24 ++ .../Remove/RemoveTests.cs | 85 +++++ test/OperationsExampleTests/TestStartup.cs | 25 ++ .../Transactions/TransactionFailureTests.cs | 80 +++++ .../Update/UpdateTests.cs | 112 +++++++ test/OperationsExampleTests/appsettings.json | 15 + 42 files changed, 2389 insertions(+), 1 deletion(-) create mode 100644 src/Examples/OperationsExample/Controllers/OperationsController.cs create mode 100644 src/Examples/OperationsExample/OperationsExample.csproj create mode 100644 src/Examples/OperationsExample/Program.cs create mode 100644 src/Examples/OperationsExample/Properties/launchSettings.json create mode 100644 src/Examples/OperationsExample/Startup.cs create mode 100644 src/Examples/OperationsExample/appsettings.json create mode 100644 src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs create mode 100644 src/JsonApiDotNetCore/Models/Operations/Operation.cs create mode 100644 src/JsonApiDotNetCore/Models/Operations/OperationCode.cs create mode 100644 src/JsonApiDotNetCore/Models/Operations/OperationsDocument.cs create mode 100644 src/JsonApiDotNetCore/Models/Operations/Params.cs create mode 100644 src/JsonApiDotNetCore/Models/Operations/ResourceReference.cs create mode 100644 src/JsonApiDotNetCore/Serialization/IOperationsDeserializer.cs create mode 100644 src/JsonApiDotNetCore/Serialization/OperationsDeserializer.cs create mode 100644 src/JsonApiDotNetCore/Services/Operations/IOpProcessor.cs create mode 100644 src/JsonApiDotNetCore/Services/Operations/OperationProcessorResolver.cs create mode 100644 src/JsonApiDotNetCore/Services/Operations/OperationsProcessor.cs create mode 100644 src/JsonApiDotNetCore/Services/Operations/Processors/CreateOpProcessor.cs create mode 100644 src/JsonApiDotNetCore/Services/Operations/Processors/GetOpProcessor.cs create mode 100644 src/JsonApiDotNetCore/Services/Operations/Processors/RemoveOpProcessor.cs create mode 100644 src/JsonApiDotNetCore/Services/Operations/Processors/UpdateOpProcessor.cs create mode 100644 test/OperationsExampleTests/Add/AddTests.cs create mode 100644 test/OperationsExampleTests/Factories/ArticleFactory.cs create mode 100644 test/OperationsExampleTests/Factories/AuthorFactory.cs create mode 100644 test/OperationsExampleTests/Fixture.cs create mode 100644 test/OperationsExampleTests/Get/GetByIdTests.cs create mode 100644 test/OperationsExampleTests/Get/GetRelationshipTests.cs create mode 100644 test/OperationsExampleTests/Get/GetTests.cs create mode 100644 test/OperationsExampleTests/OperationsExampleTests.csproj create mode 100644 test/OperationsExampleTests/Remove/RemoveTests.cs create mode 100644 test/OperationsExampleTests/TestStartup.cs create mode 100644 test/OperationsExampleTests/Transactions/TransactionFailureTests.cs create mode 100644 test/OperationsExampleTests/Update/UpdateTests.cs create mode 100644 test/OperationsExampleTests/appsettings.json diff --git a/src/Examples/OperationsExample/Controllers/OperationsController.cs b/src/Examples/OperationsExample/Controllers/OperationsController.cs new file mode 100644 index 0000000000..b0a3a38160 --- /dev/null +++ b/src/Examples/OperationsExample/Controllers/OperationsController.cs @@ -0,0 +1,14 @@ +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services.Operations; +using Microsoft.AspNetCore.Mvc; + +namespace OperationsExample.Controllers +{ + [Route("api/bulk")] + public class OperationsController : JsonApiOperationsController + { + public OperationsController(IOperationsProcessor processor) + : base(processor) + { } + } +} diff --git a/src/Examples/OperationsExample/OperationsExample.csproj b/src/Examples/OperationsExample/OperationsExample.csproj new file mode 100644 index 0000000000..efb3f2b3d4 --- /dev/null +++ b/src/Examples/OperationsExample/OperationsExample.csproj @@ -0,0 +1,30 @@ + + + $(NetCoreAppVersion) + true + OperationsExample + Exe + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Examples/OperationsExample/Program.cs b/src/Examples/OperationsExample/Program.cs new file mode 100644 index 0000000000..940a93148f --- /dev/null +++ b/src/Examples/OperationsExample/Program.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; + +namespace OperationsExample +{ + public class Program + { + public static void Main(string[] args) => BuildWebHost(args).Run(); + + public static IWebHost BuildWebHost(string[] args) => + WebHost.CreateDefaultBuilder(args) + .UseStartup() + .Build(); + } +} diff --git a/src/Examples/OperationsExample/Properties/launchSettings.json b/src/Examples/OperationsExample/Properties/launchSettings.json new file mode 100644 index 0000000000..b0d8e5bd4b --- /dev/null +++ b/src/Examples/OperationsExample/Properties/launchSettings.json @@ -0,0 +1,27 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:53656/", + "sslPort": 0 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "OperationsExample": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:53657/" + } + } +} \ No newline at end of file diff --git a/src/Examples/OperationsExample/Startup.cs b/src/Examples/OperationsExample/Startup.cs new file mode 100644 index 0000000000..a889ad85d6 --- /dev/null +++ b/src/Examples/OperationsExample/Startup.cs @@ -0,0 +1,55 @@ +using System; +using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCoreExample.Data; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace OperationsExample +{ + public class Startup + { + public readonly IConfiguration Config; + + public Startup(IHostingEnvironment env) + { + var builder = new ConfigurationBuilder() + .SetBasePath(env.ContentRootPath) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) + .AddEnvironmentVariables(); + + Config = builder.Build(); + } + + public virtual IServiceProvider ConfigureServices(IServiceCollection services) + { + var loggerFactory = new LoggerFactory(); + loggerFactory.AddConsole(LogLevel.Warning); + + services.AddSingleton(loggerFactory); + + services.AddDbContext(options => options.UseNpgsql(GetDbConnectionString()), ServiceLifetime.Scoped); + + services.AddJsonApi(opt => opt.EnableOperations = true); + + return services.BuildServiceProvider(); + } + + public virtual void Configure( + IApplicationBuilder app, + IHostingEnvironment env, + ILoggerFactory loggerFactory, + AppDbContext context) + { + context.Database.EnsureCreated(); + + loggerFactory.AddConsole(Config.GetSection("Logging")); + app.UseJsonApi(); + } + + public string GetDbConnectionString() => Config["Data:DefaultConnection"]; + } +} diff --git a/src/Examples/OperationsExample/appsettings.json b/src/Examples/OperationsExample/appsettings.json new file mode 100644 index 0000000000..c468439079 --- /dev/null +++ b/src/Examples/OperationsExample/appsettings.json @@ -0,0 +1,13 @@ +{ + "Data": { + "DefaultConnection": "Host=localhost;Port=5432;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=postgres" + }, + "Logging": { + "IncludeScopes": false, + "LogLevel": { + "Default": "Warning", + "System": "Warning", + "Microsoft": "Warning" + } + } +} diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index 96717fe796..b81cab602f 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -172,6 +172,8 @@ public interface IJsonApiOptions /// int? MaximumIncludeDepth { get; } + bool EnableOperations { get; set; } + /// /// 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..e09a71e92f 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -132,6 +132,11 @@ public void ConfigureServiceContainer(ICollection dbContextTypes) } } + if (jsonApiOptions.EnableOperations) + { + AddOperationServices(services); + } + AddResourceLayer(); AddRepositoryLayer(); AddServiceLayer(); @@ -265,6 +270,26 @@ private void AddSerializationLayer() _services.AddScoped(typeof(ResponseSerializer<>)); _services.AddScoped(sp => sp.GetRequiredService().GetSerializer()); _services.AddScoped(); + services.AddScoped(); + } + + private static void AddOperationServices(IServiceCollection services) + { + services.AddScoped(); + + services.AddScoped(typeof(ICreateOpProcessor<>), typeof(CreateOpProcessor<>)); + services.AddScoped(typeof(ICreateOpProcessor<,>), typeof(CreateOpProcessor<,>)); + + services.AddScoped(typeof(IGetOpProcessor<>), typeof(GetOpProcessor<>)); + services.AddScoped(typeof(IGetOpProcessor<,>), typeof(GetOpProcessor<,>)); + + services.AddScoped(typeof(IRemoveOpProcessor<>), typeof(RemoveOpProcessor<>)); + services.AddScoped(typeof(IRemoveOpProcessor<,>), typeof(RemoveOpProcessor<,>)); + + services.AddScoped(typeof(IUpdateOpProcessor<>), typeof(UpdateOpProcessor<>)); + services.AddScoped(typeof(IUpdateOpProcessor<,>), typeof(UpdateOpProcessor<,>)); + + services.AddScoped(); } private void AddResourcesFromDbContext(DbContext dbContext, ResourceGraphBuilder builder) diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index c999508574..c422be27c7 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -67,6 +67,17 @@ public sealed class JsonApiOptions : IJsonApiOptions /// public int? MaximumIncludeDepth { get; set; } + /// + /// Whether or not to allow json:api v1.1 operation requests. + /// This is a beta feature and there may be breaking changes + /// in subsequent releases. For now, ijt should be considered + /// experimental. + /// + /// + /// This will be enabled by default in a subsequent patch JsonApiDotNetCore v2.2.x + /// + public bool EnableOperations { get; set; } + /// public JsonSerializerSettings SerializerSettings { get; } = new JsonSerializerSettings { diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs new file mode 100644 index 0000000000..f6db9f0d06 --- /dev/null +++ b/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs @@ -0,0 +1,60 @@ +using System.Threading.Tasks; +using JsonApiDotNetCore.Models.Operations; +using JsonApiDotNetCore.Services.Operations; +using Microsoft.AspNetCore.Mvc; + +namespace JsonApiDotNetCore.Controllers +{ + /// + /// A controller to be used for bulk operations as defined in the json:api 1.1 specification + /// + public class JsonApiOperationsController : ControllerBase + { + private readonly IOperationsProcessor _operationsProcessor; + + /// + /// The processor to handle bulk operations. + /// + public JsonApiOperationsController(IOperationsProcessor operationsProcessor) + { + _operationsProcessor = operationsProcessor; + } + + /// + /// Bulk endpoint for json:api operations + /// + /// + /// A json:api operations request document + /// + /// + /// + /// PATCH /api/bulk HTTP/1.1 + /// Content-Type: application/vnd.api+json + /// + /// { + /// "operations": [{ + /// "op": "add", + /// "ref": { + /// "type": "authors" + /// }, + /// "data": { + /// "type": "authors", + /// "attributes": { + /// "name": "jaredcnance" + /// } + /// } + /// }] + /// } + /// + /// + [HttpPatch] + public virtual async Task PatchAsync([FromBody] OperationsDocument doc) + { + if (doc == null) return new StatusCodeResult(422); + + var results = await _operationsProcessor.ProcessAsync(doc.Operations); + + return Ok(new OperationsDocument(results)); + } + } +} diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs index 3ea888e4ff..b4ebe2d247 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs @@ -57,5 +57,7 @@ public interface IJsonApiRequest /// Indicates whether this request targets only fetching of data (such as resources and relationships). /// bool IsReadOnly { get; } + + bool IsBulkRequest { get; set; } } } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index 026e50682a..832982d4ab 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -55,6 +55,8 @@ public async Task Invoke(HttpContext httpContext, return; } + _currentRequest.IsBulkRequest = PathIsBulk(); + SetupRequest((JsonApiRequest)request, primaryResourceContext, routeValues, options, resourceContextProvider, httpContext.Request); httpContext.RegisterJsonApiRequest(); @@ -63,6 +65,12 @@ public async Task Invoke(HttpContext httpContext, await _next(httpContext); } + private bool PathIsBulk() + { + var actionName = (string)_httpContext.GetRouteData().Values["action"]; + return actionName.ToLower().Contains("bulk"); + } + private static ResourceContext CreatePrimaryResourceContext(RouteValueDictionary routeValues, IControllerResourceMapping controllerResourceMapping, IResourceContextProvider resourceContextProvider) { diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs index 5080c5f9e2..2ccadcfcbd 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs @@ -29,5 +29,8 @@ public sealed class JsonApiRequest : IJsonApiRequest /// public bool IsReadOnly { get; set; } + + /// + public bool IsBulkRequest { get; set; } } } diff --git a/src/JsonApiDotNetCore/Models/Operations/Operation.cs b/src/JsonApiDotNetCore/Models/Operations/Operation.cs new file mode 100644 index 0000000000..be6310c0da --- /dev/null +++ b/src/JsonApiDotNetCore/Models/Operations/Operation.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Models.Links; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; + +namespace JsonApiDotNetCore.Models.Operations +{ + public class Operation + { + [JsonProperty("links", NullValueHandling = NullValueHandling.Ignore)] + public TopLevelLinks Links { get; set; } + + [JsonProperty("included", NullValueHandling = NullValueHandling.Ignore)] + public List Included { get; set; } + + [JsonProperty("meta", NullValueHandling = NullValueHandling.Ignore)] + public Dictionary Meta { get; set; } + + [JsonProperty("op"), JsonConverter(typeof(StringEnumConverter))] + public OperationCode Op { get; set; } + + [JsonProperty("ref", NullValueHandling = NullValueHandling.Ignore)] + public ResourceReference Ref { get; set; } + + [JsonProperty("params", NullValueHandling = NullValueHandling.Ignore)] + public Params Params { get; set; } + + [JsonProperty("data")] + public object Data + { + get + { + if (DataIsList) return DataList; + return DataObject; + } + set => SetData(value); + } + + private void SetData(object data) + { + if (data is JArray jArray) + { + DataIsList = true; + DataList = jArray.ToObject>(); + } + else if (data is List dataList) + { + DataIsList = true; + DataList = dataList; + } + else if (data is JObject jObject) + { + DataObject = jObject.ToObject(); + } + else if (data is ResourceObject dataObject) + { + DataObject = dataObject; + } + } + + [JsonIgnore] + public bool DataIsList { get; private set; } + + [JsonIgnore] + public List DataList { get; private set; } + + [JsonIgnore] + public ResourceObject DataObject { get; private set; } + + public string GetResourceTypeName() + { + if (Ref != null) + return Ref.Type?.ToString(); + + if (DataIsList) + return DataList[0].Type?.ToString(); + + return DataObject.Type?.ToString(); + } + } +} diff --git a/src/JsonApiDotNetCore/Models/Operations/OperationCode.cs b/src/JsonApiDotNetCore/Models/Operations/OperationCode.cs new file mode 100644 index 0000000000..6b6905cd59 --- /dev/null +++ b/src/JsonApiDotNetCore/Models/Operations/OperationCode.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace JsonApiDotNetCore.Models.Operations +{ + [JsonConverter(typeof(StringEnumConverter))] + public enum OperationCode + { + get = 1, + add = 2, + update = 3, + remove = 4 + } +} diff --git a/src/JsonApiDotNetCore/Models/Operations/OperationsDocument.cs b/src/JsonApiDotNetCore/Models/Operations/OperationsDocument.cs new file mode 100644 index 0000000000..3228e9ca88 --- /dev/null +++ b/src/JsonApiDotNetCore/Models/Operations/OperationsDocument.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Models.Operations +{ + public class OperationsDocument + { + public OperationsDocument() { } + public OperationsDocument(List operations) + { + Operations = operations; + } + + [JsonProperty("operations")] + public List Operations { get; set; } + } +} diff --git a/src/JsonApiDotNetCore/Models/Operations/Params.cs b/src/JsonApiDotNetCore/Models/Operations/Params.cs new file mode 100644 index 0000000000..470e8f4aa3 --- /dev/null +++ b/src/JsonApiDotNetCore/Models/Operations/Params.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace JsonApiDotNetCore.Models.Operations +{ + public class Params + { + public List Include { get; set; } + public List Sort { get; set; } + public Dictionary Filter { get; set; } + public string Page { get; set; } + public Dictionary Fields { get; set; } + } +} diff --git a/src/JsonApiDotNetCore/Models/Operations/ResourceReference.cs b/src/JsonApiDotNetCore/Models/Operations/ResourceReference.cs new file mode 100644 index 0000000000..5291d61a19 --- /dev/null +++ b/src/JsonApiDotNetCore/Models/Operations/ResourceReference.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Models.Operations +{ + public class ResourceReference : ResourceIdentifierObject + { + [JsonProperty("relationship")] + public string Relationship { get; set; } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/IOperationsDeserializer.cs b/src/JsonApiDotNetCore/Serialization/IOperationsDeserializer.cs new file mode 100644 index 0000000000..bc6c2f7726 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/IOperationsDeserializer.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Models; + +namespace JsonApiDotNetCore.Serialization.Deserializer +{ + public interface IOperationsDeserializer + { + object Deserialize(string body); + object DocumentToObject(ResourceObject data, List included = null); + } +} diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index b63b7da0ad..4192c33a88 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs @@ -22,12 +22,14 @@ namespace JsonApiDotNetCore.Serialization /// public class JsonApiReader : IJsonApiReader { + private readonly IOperationsDeserializer _operationsDeserializer; private readonly IJsonApiDeserializer _deserializer; private readonly IJsonApiRequest _request; private readonly IResourceContextProvider _resourceContextProvider; private readonly TraceLogWriter _traceWriter; public JsonApiReader(IJsonApiDeserializer deserializer, + IOperationsDeserializer operationsDeserializer, IJsonApiRequest request, IResourceContextProvider resourceContextProvider, ILoggerFactory loggerFactory) @@ -35,6 +37,7 @@ public JsonApiReader(IJsonApiDeserializer deserializer, if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); _deserializer = deserializer ?? throw new ArgumentNullException(nameof(deserializer)); + _operationsDeserializer = operationsDeserializer ?? throw new ArgumentNullException(nameof(operationsDeserializer)); _request = request ?? throw new ArgumentNullException(nameof(request)); _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); _traceWriter = new TraceLogWriter(loggerFactory); @@ -47,6 +50,12 @@ public async Task ReadAsync(InputFormatterContext context) string body = await GetRequestBodyAsync(context.HttpContext.Request.Body); + if (_currentRequest.IsBulkRequest) + { + var operations = _operationsDeserializer.Deserialize(body); + return InputFormatterResult.SuccessAsync(operations); + } + string url = context.HttpContext.Request.GetEncodedUrl(); _traceWriter.LogMessage(() => $"Received request at '{url}' with body: <<{body}>>"); diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs index f3e0268a81..d5479678ca 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs @@ -10,6 +10,13 @@ public class ResourceIdentifierObject [JsonProperty("id", NullValueHandling = NullValueHandling.Ignore, Order = -2)] public string Id { get; set; } - public override string ToString() => $"(type: {Type}, id: {Id})"; + [JsonIgnore] + //[JsonProperty("lid")] + public string LocalId { get; set; } + + public override string ToString() + { + return $"(type: {Type}, id: {Id})"; + } } } diff --git a/src/JsonApiDotNetCore/Serialization/OperationsDeserializer.cs b/src/JsonApiDotNetCore/Serialization/OperationsDeserializer.cs new file mode 100644 index 0000000000..07cb4489c5 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/OperationsDeserializer.cs @@ -0,0 +1,299 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Operations; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace JsonApiDotNetCore.Serialization.Deserializer +{ + /// + /// Legacy document parser to be used for Bulk requests. + /// Will probably remove this for v4. + /// + public class OperationsDeserializer : IOperationsDeserializer + { + private readonly ITargetedFields _targetedFieldsManager; + private readonly IResourceGraph _resourceGraph; + private readonly JsonSerializer _jsonSerializer; + + public OperationsDeserializer(ITargetedFields updatedFieldsManager, + IResourceGraph resourceGraph) + { + _targetedFieldsManager = updatedFieldsManager; + _resourceGraph = resourceGraph; + } + + public object Deserialize(string requestBody) + { + try + { + JToken bodyJToken; + using (JsonReader jsonReader = new JsonTextReader(new StringReader(requestBody))) + { + jsonReader.DateParseHandling = DateParseHandling.None; + bodyJToken = JToken.Load(jsonReader); + } + var operations = JsonConvert.DeserializeObject(requestBody); + if (operations == null) + throw new JsonApiException(400, "Failed to deserialize operations request."); + + return operations; + } + catch (JsonApiException) + { + throw; + } + catch (Exception e) + { + throw new JsonApiException(400, "Failed to deserialize request body", e); + } + } + + public object DocumentToObject(ResourceObject data, List included = null) + { + if (data == null) + throw new JsonApiException(422, "Failed to deserialize document as json:api."); + + var contextEntity = _resourceGraph.GetContextEntity(data.Type?.ToString()); + if (contextEntity == null) + { + throw new JsonApiException(400, + message: $"This API does not contain a json:api resource named '{data.Type}'.", + detail: "This resource is not registered on the ResourceGraph. " + + "If you are using Entity Framework, make sure the DbSet matches the expected resource name. " + + "If you have manually registered the resource, check that the call to AddResource correctly sets the public name."); + } + + + var entity = Activator.CreateInstance(contextEntity.EntityType); + + entity = SetEntityAttributes(entity, contextEntity, data.Attributes); + entity = SetRelationships(entity, contextEntity, data.Relationships, included); + + var identifiableEntity = (IIdentifiable)entity; + + if (data.Id != null) + identifiableEntity.StringId = data.Id?.ToString(); + + return identifiableEntity; + } + + private object SetEntityAttributes( + object entity, ContextEntity contextEntity, Dictionary attributeValues) + { + if (attributeValues == null || attributeValues.Count == 0) + return entity; + + foreach (var attr in contextEntity.Attributes) + { + if (attributeValues.TryGetValue(attr.PublicAttributeName, out object newValue)) + { + if (attr.IsImmutable) + continue; + var convertedValue = ConvertAttrValue(newValue, attr.PropertyInfo.PropertyType); + attr.SetValue(entity, convertedValue); + _targetedFieldsManager.Attributes.Add(attr); + } + } + + return entity; + } + + private object ConvertAttrValue(object newValue, Type targetType) + { + if (newValue is JContainer jObject) + return DeserializeComplexType(jObject, targetType); + + var convertedValue = TypeHelper.ConvertType(newValue, targetType); + return convertedValue; + } + + private object DeserializeComplexType(JContainer obj, Type targetType) + { + return obj.ToObject(targetType, _jsonSerializer); + } + + private object SetRelationships( + object entity, + ContextEntity contextEntity, + Dictionary relationships, + List included = null) + { + if (relationships == null || relationships.Count == 0) + return entity; + + var entityProperties = entity.GetType().GetProperties(); + + foreach (var attr in contextEntity.Relationships) + { + entity = attr.IsHasOne + ? SetHasOneRelationship(entity, entityProperties, (HasOneAttribute)attr, contextEntity, relationships, included) + : SetHasManyRelationship(entity, entityProperties, (HasManyAttribute)attr, contextEntity, relationships, included); + } + + return entity; + } + + private object SetHasOneRelationship(object entity, + PropertyInfo[] entityProperties, + HasOneAttribute attr, + ContextEntity contextEntity, + Dictionary relationships, + List included = null) + { + var relationshipName = attr.PublicRelationshipName; + + if (relationships.TryGetValue(relationshipName, out RelationshipEntry relationshipData) == false) + return entity; + + var rio = (ResourceIdentifierObject)relationshipData.Data; + + var foreignKey = attr.IdentifiablePropertyName; + var foreignKeyProperty = entityProperties.FirstOrDefault(p => p.Name == foreignKey); + if (foreignKeyProperty == null && rio == null) + return entity; + + SetHasOneForeignKeyValue(entity, attr, foreignKeyProperty, rio); + SetHasOneNavigationPropertyValue(entity, attr, rio, included); + + // recursive call ... + if (included != null) + { + var navigationPropertyValue = attr.GetValue(entity); + + var resourceGraphEntity = _resourceGraph.GetContextEntity(attr.DependentType); + if (navigationPropertyValue != null && resourceGraphEntity != null) + + { + var includedResource = included.SingleOrDefault(r => r.Type == rio.Type && r.Id == rio.Id); + if (includedResource != null) + SetRelationships(navigationPropertyValue, resourceGraphEntity, includedResource.Relationships, included); + } + } + + return entity; + } + + private void SetHasOneForeignKeyValue(object entity, HasOneAttribute hasOneAttr, PropertyInfo foreignKeyProperty, ResourceIdentifierObject rio) + { + var foreignKeyPropertyValue = rio?.Id ?? null; + if (foreignKeyProperty != null) + { + // in the case of the HasOne independent side of the relationship, we should still create the shell entity on the other side + // we should not actually require the resource to have a foreign key (be the dependent side of the relationship) + + // e.g. PATCH /articles + // {... { "relationships":{ "Owner": { "data": null } } } } + bool foreignKeyPropertyIsNullableType = Nullable.GetUnderlyingType(foreignKeyProperty.PropertyType) != null + || foreignKeyProperty.PropertyType == typeof(string); + if (rio == null && !foreignKeyPropertyIsNullableType) + throw new JsonApiException(400, $"Cannot set required relationship identifier '{hasOneAttr.IdentifiablePropertyName}' to null because it is a non-nullable type."); + + var convertedValue = TypeHelper.ConvertType(foreignKeyPropertyValue, foreignKeyProperty.PropertyType); + /// todo: as a part of the process of decoupling JADNC (specifically + /// through the decoupling IJsonApiContext), we now no longer need to + /// store the updated relationship values in this property. For now + /// just assigning null as value, will remove this property later as a whole. + /// see #512 + if (convertedValue == null) _targetedFieldsManager.Relationships.Add(hasOneAttr); + } + } + + /// + /// Sets the value of the navigation property for the related resource. + /// If the resource has been included, all attributes will be set. + /// If the resource has not been included, only the id will be set. + /// + private void SetHasOneNavigationPropertyValue(object entity, HasOneAttribute hasOneAttr, ResourceIdentifierObject rio, List included) + { + // if the resource identifier is null, there should be no reason to instantiate an instance + if (rio != null && rio.Id != null) + { + // we have now set the FK property on the resource, now we need to check to see if the + // related entity was included in the payload and update its attributes + var includedRelationshipObject = GetIncludedRelationship(rio, included, hasOneAttr); + if (includedRelationshipObject != null) + hasOneAttr.SetValue(entity, includedRelationshipObject); + + /// todo: as a part of the process of decoupling JADNC (specifically + /// through the decoupling IJsonApiContext), we now no longer need to + /// store the updated relationship values in this property. For now + /// just assigning null as value, will remove this property later as a whole. + /// see #512 + _targetedFieldsManager.Relationships.Add(hasOneAttr); + } + } + + private object SetHasManyRelationship(object entity, + PropertyInfo[] entityProperties, + HasManyAttribute attr, + ContextEntity contextEntity, + Dictionary relationships, + List included = null) + { + var relationshipName = attr.PublicRelationshipName; + + if (relationships.TryGetValue(relationshipName, out RelationshipEntry relationshipData)) + { + if (relationshipData.IsManyData == false) + return entity; + + var relatedResources = relationshipData.ManyData.Select(r => + { + var instance = GetIncludedRelationship(r, included, attr); + return instance; + }); + + var convertedCollection = TypeHelper.ConvertCollection(relatedResources, attr.DependentType); + + attr.SetValue(entity, convertedCollection); + _targetedFieldsManager.Relationships.Add(attr); + } + + return entity; + } + + private IIdentifiable GetIncludedRelationship(ResourceIdentifierObject relatedResourceIdentifier, List includedResources, RelationshipAttribute relationshipAttr) + { + // at this point we can be sure the relationshipAttr.Type is IIdentifiable because we were able to successfully build the ResourceGraph + var relatedInstance = relationshipAttr.DependentType.New(); + relatedInstance.StringId = relatedResourceIdentifier.Id; + + // can't provide any more data other than the rio since it is not contained in the included section + if (includedResources == null || includedResources.Count == 0) + return relatedInstance; + + var includedResource = GetLinkedResource(relatedResourceIdentifier, includedResources); + if (includedResource == null) + return relatedInstance; + + var contextEntity = _resourceGraph.GetContextEntity(relationshipAttr.DependentType); + if (contextEntity == null) + throw new JsonApiException(400, $"Included type '{relationshipAttr.DependentType}' is not a registered json:api resource."); + + SetEntityAttributes(relatedInstance, contextEntity, includedResource.Attributes); + + return relatedInstance; + } + + private ResourceObject GetLinkedResource(ResourceIdentifierObject relatedResourceIdentifier, List includedResources) + { + try + { + return includedResources.SingleOrDefault(r => r.Type == relatedResourceIdentifier.Type && r.Id == relatedResourceIdentifier.Id); + } + catch (InvalidOperationException e) + { + throw new JsonApiException(400, $"A compound document MUST NOT include more than one resource object for each type and id pair." + + $"The duplicate pair was '{relatedResourceIdentifier.Type}, {relatedResourceIdentifier.Id}'", e); + } + } + } +} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Services/Operations/IOpProcessor.cs b/src/JsonApiDotNetCore/Services/Operations/IOpProcessor.cs new file mode 100644 index 0000000000..0a2d30397c --- /dev/null +++ b/src/JsonApiDotNetCore/Services/Operations/IOpProcessor.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using JsonApiDotNetCore.Models.Operations; + +namespace JsonApiDotNetCore.Services.Operations +{ + public interface IOpProcessor + { + Task ProcessAsync(Operation operation); + } +} diff --git a/src/JsonApiDotNetCore/Services/Operations/OperationProcessorResolver.cs b/src/JsonApiDotNetCore/Services/Operations/OperationProcessorResolver.cs new file mode 100644 index 0000000000..41dc43a1f7 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/Operations/OperationProcessorResolver.cs @@ -0,0 +1,115 @@ +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Internal.Generics; +using JsonApiDotNetCore.Models.Operations; +using JsonApiDotNetCore.Services.Operations.Processors; + +namespace JsonApiDotNetCore.Services.Operations +{ + /// + /// Used to resolve at runtime based on the required operation + /// + public interface IOperationProcessorResolver + { + /// + /// Locates the correct + /// + IOpProcessor LocateCreateService(Operation operation); + + /// + /// Locates the correct + /// + IOpProcessor LocateGetService(Operation operation); + + /// + /// Locates the correct + /// + IOpProcessor LocateRemoveService(Operation operation); + + /// + /// Locates the correct + /// + IOpProcessor LocateUpdateService(Operation operation); + } + + /// + public class OperationProcessorResolver : IOperationProcessorResolver + { + private readonly IGenericProcessorFactory _processorFactory; + private readonly IContextEntityProvider _provider; + + /// + public OperationProcessorResolver( + IGenericProcessorFactory processorFactory, + IContextEntityProvider provider) + { + _processorFactory = processorFactory; + _provider = provider; + } + + /// + public IOpProcessor LocateCreateService(Operation operation) + { + var resource = operation.GetResourceTypeName(); + + var contextEntity = GetResourceMetadata(resource); + + var processor = _processorFactory.GetProcessor( + typeof(ICreateOpProcessor<,>), contextEntity.EntityType, contextEntity.IdentityType + ); + + return processor; + } + + /// + public IOpProcessor LocateGetService(Operation operation) + { + var resource = operation.GetResourceTypeName(); + + var contextEntity = GetResourceMetadata(resource); + + var processor = _processorFactory.GetProcessor( + typeof(IGetOpProcessor<,>), contextEntity.EntityType, contextEntity.IdentityType + ); + + return processor; + } + + /// + public IOpProcessor LocateRemoveService(Operation operation) + { + var resource = operation.GetResourceTypeName(); + + var contextEntity = GetResourceMetadata(resource); + + var processor = _processorFactory.GetProcessor( + typeof(IRemoveOpProcessor<,>), contextEntity.EntityType, contextEntity.IdentityType + ); + + return processor; + } + + /// + public IOpProcessor LocateUpdateService(Operation operation) + { + var resource = operation.GetResourceTypeName(); + + var contextEntity = GetResourceMetadata(resource); + + var processor = _processorFactory.GetProcessor( + typeof(IUpdateOpProcessor<,>), contextEntity.EntityType, contextEntity.IdentityType + ); + + return processor; + } + + private ContextEntity GetResourceMetadata(string resourceName) + { + var contextEntity = _provider.GetContextEntity(resourceName); + if(contextEntity == null) + throw new JsonApiException(400, $"This API does not expose a resource of type '{resourceName}'."); + + return contextEntity; + } + } +} diff --git a/src/JsonApiDotNetCore/Services/Operations/OperationsProcessor.cs b/src/JsonApiDotNetCore/Services/Operations/OperationsProcessor.cs new file mode 100644 index 0000000000..fcbff7c613 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/Operations/OperationsProcessor.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using JsonApiDotNetCore.Data; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Managers.Contracts; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Operations; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCore.Services.Operations +{ + public interface IOperationsProcessor + { + Task> ProcessAsync(List inputOps); + } + + public class OperationsProcessor : IOperationsProcessor + { + private readonly IOperationProcessorResolver _processorResolver; + private readonly DbContext _dbContext; + private readonly ICurrentRequest _currentRequest; + private readonly IResourceGraph _resourceGraph; + + public OperationsProcessor( + IOperationProcessorResolver processorResolver, + IDbContextResolver dbContextResolver, + ICurrentRequest currentRequest, + IResourceGraph resourceGraph) + { + _processorResolver = processorResolver; + _dbContext = dbContextResolver.GetContext(); + _currentRequest = currentRequest; + _resourceGraph = resourceGraph; + } + + public async Task> ProcessAsync(List inputOps) + { + var outputOps = new List(); + var opIndex = 0; + OperationCode? lastAttemptedOperation = null; // used for error messages only + + using (var transaction = await _dbContext.Database.BeginTransactionAsync()) + { + try + { + foreach (var op in inputOps) + { + //_jsonApiContext.BeginOperation(); + + lastAttemptedOperation = op.Op; + await ProcessOperation(op, outputOps); + opIndex++; + } + + transaction.Commit(); + return outputOps; + } + catch (JsonApiException e) + { + transaction.Rollback(); + throw new JsonApiException(e.GetStatusCode(), $"Transaction failed on operation[{opIndex}] ({lastAttemptedOperation}).", e); + } + catch (Exception e) + { + transaction.Rollback(); + throw new JsonApiException(500, $"Transaction failed on operation[{opIndex}] ({lastAttemptedOperation}) for an unexpected reason.", e); + } + } + } + + private async Task ProcessOperation(Operation op, List outputOps) + { + ReplaceLocalIdsInResourceObject(op.DataObject, outputOps); + ReplaceLocalIdsInRef(op.Ref, outputOps); + + string type = null; + if (op.Op == OperationCode.add || op.Op == OperationCode.update) + { + type = op.DataObject.Type; + } + else if (op.Op == OperationCode.get || op.Op == OperationCode.remove) + { + type = op.Ref.Type; + } + _currentRequest.SetRequestResource(_resourceGraph.GetEntityFromControllerName(type)); + + var processor = GetOperationsProcessor(op); + var resultOp = await processor.ProcessAsync(op); + + if (resultOp != null) + outputOps.Add(resultOp); + } + + private void ReplaceLocalIdsInResourceObject(ResourceObject resourceObject, List outputOps) + { + if (resourceObject == null) + return; + + // it is strange to me that a top level resource object might use a lid. + // by not replacing it, we avoid a case where the first operation is an 'add' with an 'lid' + // and we would be unable to locate the matching 'lid' in 'outputOps' + // + // we also create a scenario where I might try to update a resource I just created + // in this case, the 'data.id' will be null, but the 'ref.id' will be replaced by the correct 'id' from 'outputOps' + // + // if(HasLocalId(resourceObject)) + // resourceObject.Id = GetIdFromLocalId(outputOps, resourceObject.LocalId); + + if (resourceObject.Relationships != null) + { + foreach (var relationshipDictionary in resourceObject.Relationships) + { + if (relationshipDictionary.Value.IsManyData) + { + foreach (var relationship in relationshipDictionary.Value.ManyData) + if (HasLocalId(relationship)) + relationship.Id = GetIdFromLocalId(outputOps, relationship.LocalId); + } + else + { + var relationship = relationshipDictionary.Value.SingleData; + if (HasLocalId(relationship)) + relationship.Id = GetIdFromLocalId(outputOps, relationship.LocalId); + } + } + } + } + + private void ReplaceLocalIdsInRef(ResourceReference resourceRef, List outputOps) + { + if (resourceRef == null) return; + if (HasLocalId(resourceRef)) + resourceRef.Id = GetIdFromLocalId(outputOps, resourceRef.LocalId); + } + + private bool HasLocalId(ResourceIdentifierObject rio) => string.IsNullOrEmpty(rio.LocalId) == false; + + private string GetIdFromLocalId(List outputOps, string localId) + { + var referencedOp = outputOps.FirstOrDefault(o => o.DataObject.LocalId == localId); + if (referencedOp == null) throw new JsonApiException(400, $"Could not locate lid '{localId}' in document."); + return referencedOp.DataObject.Id; + } + + private IOpProcessor GetOperationsProcessor(Operation op) + { + switch (op.Op) + { + case OperationCode.add: + return _processorResolver.LocateCreateService(op); + case OperationCode.get: + return _processorResolver.LocateGetService(op); + case OperationCode.remove: + return _processorResolver.LocateRemoveService(op); + case OperationCode.update: + return _processorResolver.LocateUpdateService(op); + default: + throw new JsonApiException(400, $"'{op.Op}' is not a valid operation code"); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Services/Operations/Processors/CreateOpProcessor.cs b/src/JsonApiDotNetCore/Services/Operations/Processors/CreateOpProcessor.cs new file mode 100644 index 0000000000..4eb5c65961 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/Operations/Processors/CreateOpProcessor.cs @@ -0,0 +1,80 @@ +using System.Threading.Tasks; +using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Operations; +using JsonApiDotNetCore.Serialization.Deserializer; + +namespace JsonApiDotNetCore.Services.Operations.Processors +{ + public interface ICreateOpProcessor : ICreateOpProcessor + where T : class, IIdentifiable + { } + + public interface ICreateOpProcessor : IOpProcessor + where T : class, IIdentifiable + { } + + public class CreateOpProcessor + : CreateOpProcessor, ICreateOpProcessor + where T : class, IIdentifiable + { + public CreateOpProcessor( + ICreateService service, + IOperationsDeserializer deserializer, + IBaseDocumentBuilder documentBuilder, + IResourceGraph resourceGraph + ) : base(service, deserializer, documentBuilder, resourceGraph) + { } + } + + public interface IBaseDocumentBuilder + { + ResourceObject GetData(ContextEntity contextEntity, IIdentifiable singleResource); + } + + public class CreateOpProcessor : ICreateOpProcessor + where T : class, IIdentifiable + { + private readonly ICreateService _service; + private readonly IOperationsDeserializer _deserializer; + private readonly IBaseDocumentBuilder _documentBuilder; + private readonly IResourceGraph _resourceGraph; + + public CreateOpProcessor( + ICreateService service, + IOperationsDeserializer deserializer, + IBaseDocumentBuilder documentBuilder, + IResourceGraph resourceGraph) + { + _service = service; + _deserializer = deserializer; + _documentBuilder = documentBuilder; + _resourceGraph = resourceGraph; + } + + public async Task ProcessAsync(Operation operation) + { + + var model = (T)_deserializer.DocumentToObject(operation.DataObject); + var result = await _service.CreateAsync(model); + + var operationResult = new Operation + { + Op = OperationCode.add + }; + + operationResult.Data = _documentBuilder.GetData( + _resourceGraph.GetContextEntity(operation.GetResourceTypeName()), + result); + + // we need to persist the original request localId so that subsequent operations + // can locate the result of this operation by its localId + operationResult.DataObject.LocalId = operation.DataObject.LocalId; + + return null; + //return operationResult; + } + } +} diff --git a/src/JsonApiDotNetCore/Services/Operations/Processors/GetOpProcessor.cs b/src/JsonApiDotNetCore/Services/Operations/Processors/GetOpProcessor.cs new file mode 100644 index 0000000000..ec2144bb77 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/Operations/Processors/GetOpProcessor.cs @@ -0,0 +1,172 @@ +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Operations; +using JsonApiDotNetCore.Serialization.Deserializer; + +namespace JsonApiDotNetCore.Services.Operations.Processors +{ + /// + /// Handles all "" operations + /// + /// The resource type + public interface IGetOpProcessor : IGetOpProcessor + where T : class, IIdentifiable + { } + + /// + /// Handles all "" operations + /// + /// The resource type + /// The resource identifier type + public interface IGetOpProcessor : IOpProcessor + where T : class, IIdentifiable + { } + + /// + public class GetOpProcessor : GetOpProcessor, IGetOpProcessor + where T : class, IIdentifiable + { + /// + public GetOpProcessor( + IGetAllService getAll, + IGetByIdService getById, + IGetRelationshipService getRelationship, + IOperationsDeserializer deserializer, + IBaseDocumentBuilder documentBuilder, + IResourceGraph resourceGraph + ) : base(getAll, getById, getRelationship, deserializer, documentBuilder, resourceGraph) + { } + } + + /// + public class GetOpProcessor : IGetOpProcessor + where T : class, IIdentifiable + { + private readonly IGetAllService _getAll; + private readonly IGetByIdService _getById; + private readonly IGetRelationshipService _getRelationship; + private readonly IOperationsDeserializer _deserializer; + private readonly IBaseDocumentBuilder _documentBuilder; + private readonly IResourceGraph _resourceGraph; + + /// + public GetOpProcessor( + IGetAllService getAll, + IGetByIdService getById, + IGetRelationshipService getRelationship, + IOperationsDeserializer deserializer, + IBaseDocumentBuilder documentBuilder, + IResourceGraph resourceGraph) + { + _getAll = getAll; + _getById = getById; + _getRelationship = getRelationship; + _deserializer = deserializer; + _documentBuilder = documentBuilder; + _resourceGraph = resourceGraph; + } + + /// + public async Task ProcessAsync(Operation operation) + { + var operationResult = new Operation + { + Op = OperationCode.get + }; + + operationResult.Data = string.IsNullOrWhiteSpace(operation.Ref.Id) + ? await GetAllAsync(operation) + : string.IsNullOrWhiteSpace(operation.Ref.Relationship) + ? await GetByIdAsync(operation) + : await GetRelationshipAsync(operation); + + return operationResult; + } + + private async Task GetAllAsync(Operation operation) + { + var result = await _getAll.GetAsync(); + + var operations = new List(); + foreach (var resource in result) + { + var doc = _documentBuilder.GetData( + _resourceGraph.GetContextEntity(operation.GetResourceTypeName()), + resource); + operations.Add(doc); + } + + return operations; + } + + private async Task GetByIdAsync(Operation operation) + { + var id = GetReferenceId(operation); + var result = await _getById.GetAsync(id); + + // this is a bit ugly but we need to bomb the entire transaction if the entity cannot be found + // in the future it would probably be better to return a result status along with the doc to + // avoid throwing exceptions on 4xx errors. + // consider response type (status, document) + if (result == null) + throw new JsonApiException(404, $"Could not find '{operation.Ref.Type}' record with id '{operation.Ref.Id}'"); + + var doc = _documentBuilder.GetData( + _resourceGraph.GetContextEntity(operation.GetResourceTypeName()), + result); + + return doc; + } + + private async Task GetRelationshipAsync(Operation operation) + { + var id = GetReferenceId(operation); + var result = await _getRelationship.GetRelationshipAsync(id, operation.Ref.Relationship); + + // TODO: need a better way to get the ContextEntity from a relationship name + // when no generic parameter is available + var relationshipType = _resourceGraph.GetContextEntity(operation.GetResourceTypeName()) + .Relationships.Single(r => r.Is(operation.Ref.Relationship)).DependentType; + + var relatedContextEntity = _resourceGraph.GetContextEntity(relationshipType); + + if (result == null) + return null; + + if (result is IIdentifiable singleResource) + return GetData(relatedContextEntity, singleResource); + + if (result is IEnumerable multipleResults) + return GetData(relatedContextEntity, multipleResults); + + throw new JsonApiException(500, + $"An unexpected type was returned from '{_getRelationship.GetType()}.{nameof(IGetRelationshipService.GetRelationshipAsync)}'.", + detail: $"Type '{result.GetType()} does not implement {nameof(IIdentifiable)} nor {nameof(IEnumerable)}'"); + } + + private ResourceObject GetData(ContextEntity contextEntity, IIdentifiable singleResource) + { + return _documentBuilder.GetData(contextEntity, singleResource); + } + + private List GetData(ContextEntity contextEntity, IEnumerable multipleResults) + { + var resources = new List(); + foreach (var singleResult in multipleResults) + { + if (singleResult is IIdentifiable resource) + resources.Add(_documentBuilder.GetData(contextEntity, resource)); + } + + return resources; + } + + private TId GetReferenceId(Operation operation) => TypeHelper.ConvertType(operation.Ref.Id); + } +} diff --git a/src/JsonApiDotNetCore/Services/Operations/Processors/RemoveOpProcessor.cs b/src/JsonApiDotNetCore/Services/Operations/Processors/RemoveOpProcessor.cs new file mode 100644 index 0000000000..c1c80d21fa --- /dev/null +++ b/src/JsonApiDotNetCore/Services/Operations/Processors/RemoveOpProcessor.cs @@ -0,0 +1,63 @@ +using System.Threading.Tasks; +using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Operations; +using JsonApiDotNetCore.Serialization.Deserializer; + +namespace JsonApiDotNetCore.Services.Operations.Processors +{ + public interface IRemoveOpProcessor : IRemoveOpProcessor + where T : class, IIdentifiable + { } + + public interface IRemoveOpProcessor : IOpProcessor + where T : class, IIdentifiable + { } + + public class RemoveOpProcessor : RemoveOpProcessor, IRemoveOpProcessor + where T : class, IIdentifiable + { + public RemoveOpProcessor( + IDeleteService service, + IOperationsDeserializer deserializer, + IBaseDocumentBuilder documentBuilder, + IResourceGraph resourceGraph + ) : base(service, deserializer, documentBuilder, resourceGraph) + { } + } + + public class RemoveOpProcessor : IRemoveOpProcessor + where T : class, IIdentifiable + { + private readonly IDeleteService _service; + private readonly IOperationsDeserializer _deserializer; + private readonly IBaseDocumentBuilder _documentBuilder; + private readonly IResourceGraph _resourceGraph; + + public RemoveOpProcessor( + IDeleteService service, + IOperationsDeserializer deserializer, + IBaseDocumentBuilder documentBuilder, + IResourceGraph resourceGraph) + { + _service = service; + _deserializer = deserializer; + _documentBuilder = documentBuilder; + _resourceGraph = resourceGraph; + } + + public async Task ProcessAsync(Operation operation) + { + var stringId = operation.Ref?.Id?.ToString(); + if (string.IsNullOrWhiteSpace(stringId)) + throw new JsonApiException(400, "The ref.id parameter is required for remove operations"); + + var id = TypeHelper.ConvertType(stringId); + var result = await _service.DeleteAsync(id); + + return null; + } + } +} diff --git a/src/JsonApiDotNetCore/Services/Operations/Processors/UpdateOpProcessor.cs b/src/JsonApiDotNetCore/Services/Operations/Processors/UpdateOpProcessor.cs new file mode 100644 index 0000000000..37d22d14f1 --- /dev/null +++ b/src/JsonApiDotNetCore/Services/Operations/Processors/UpdateOpProcessor.cs @@ -0,0 +1,73 @@ +using System.Threading.Tasks; +using JsonApiDotNetCore.Builders; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Models.Operations; +using JsonApiDotNetCore.Serialization.Deserializer; + +namespace JsonApiDotNetCore.Services.Operations.Processors +{ + public interface IUpdateOpProcessor : IUpdateOpProcessor + where T : class, IIdentifiable + { } + + public interface IUpdateOpProcessor : IOpProcessor + where T : class, IIdentifiable + { } + + public class UpdateOpProcessor : UpdateOpProcessor, IUpdateOpProcessor + where T : class, IIdentifiable + { + public UpdateOpProcessor( + IUpdateService service, + IOperationsDeserializer deserializer, + IBaseDocumentBuilder documentBuilder, + IResourceGraph resourceGraph + ) : base(service, deserializer, documentBuilder, resourceGraph) + { } + } + + public class UpdateOpProcessor : IUpdateOpProcessor + where T : class, IIdentifiable + { + private readonly IUpdateService _service; + private readonly IOperationsDeserializer _deserializer; + private readonly IBaseDocumentBuilder _documentBuilder; + private readonly IResourceGraph _resourceGraph; + + public UpdateOpProcessor( + IUpdateService service, + IOperationsDeserializer deserializer, + IBaseDocumentBuilder documentBuilder, + IResourceGraph resourceGraph) + { + _service = service; + _deserializer = deserializer; + _documentBuilder = documentBuilder; + _resourceGraph = resourceGraph; + } + + public async Task ProcessAsync(Operation operation) + { + if (string.IsNullOrWhiteSpace(operation?.DataObject?.Id?.ToString())) + throw new JsonApiException(400, "The data.id parameter is required for replace operations"); + + //var model = (T)_deserializer.DocumentToObject(operation.DataObject); + T model = null; // TODO + + var result = await _service.UpdateAsync(model.Id, model); + if (result == null) + throw new JsonApiException(404, $"Could not find an instance of '{operation.DataObject.Type}' with id {operation.DataObject.Id}"); + + var operationResult = new Operation + { + Op = OperationCode.update + }; + + operationResult.Data = _documentBuilder.GetData(_resourceGraph.GetContextEntity(operation.GetResourceTypeName()), result); + + return operationResult; + } + } +} diff --git a/test/OperationsExampleTests/Add/AddTests.cs b/test/OperationsExampleTests/Add/AddTests.cs new file mode 100644 index 0000000000..0deaaa6a82 --- /dev/null +++ b/test/OperationsExampleTests/Add/AddTests.cs @@ -0,0 +1,294 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Bogus; +using JsonApiDotNetCore.Models.Operations; +using JsonApiDotNetCoreExample.Data; +using Microsoft.EntityFrameworkCore; +using OperationsExampleTests.Factories; +using Xunit; + +namespace OperationsExampleTests +{ + public class AddTests : Fixture + { + private readonly Faker _faker = new Faker(); + + [Fact] + public async Task Can_Create_Author() + { + // arrange + var context = GetService(); + var author = AuthorFactory.Get(); + var content = new + { + operations = new[] { + new { + op = "add", + data = new { + type = "authors", + attributes = new { + name = author.Name + } + } + } + } + }; + + // act + var (response, data) = await PatchAsync("api/bulk", content); + + // assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var id = data.Operations.Single().DataObject.Id; + var lastAuthor = await context.Authors.SingleAsync(a => a.StringId == id); + Assert.Equal(author.Name, lastAuthor.Name); + } + + [Fact] + public async Task Can_Create_Authors() + { + // arrange + var expectedCount = _faker.Random.Int(1, 10); + var context = GetService(); + var authors = AuthorFactory.Get(expectedCount); + var content = new + { + operations = new List() + }; + + for (int i = 0; i < expectedCount; i++) + { + content.operations.Add( + new + { + op = "add", + data = new + { + type = "authors", + attributes = new + { + name = authors[i].Name + } + } + } + ); + } + + // act + var (response, data) = await PatchAsync("api/bulk", content); + + // assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(expectedCount, data.Operations.Count); + + for (int i = 0; i < expectedCount; i++) + { + var dataObject = data.Operations[i].DataObject; + var author = context.Authors.Single(a => a.StringId == dataObject.Id); + Assert.Equal(authors[i].Name, author.Name); + } + } + + [Fact] + public async Task Can_Create_Article_With_Existing_Author() + { + // arrange + var context = GetService(); + var author = AuthorFactory.Get(); + var article = ArticleFactory.Get(); + + context.Authors.Add(author); + await context.SaveChangesAsync(); + + + //const string authorLocalId = "author-1"; + + var content = new + { + operations = new object[] { + new { + op = "add", + data = new { + type = "articles", + attributes = new { + name = article.Name + }, + relationships = new { + author = new { + data = new { + type = "authors", + id = author.Id + } + } + } + } + } + } + }; + + // act + var (response, data) = await PatchAsync("api/bulk", content); + + // assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Single(data.Operations); + + + var lastAuthor = await context.Authors + .Include(a => a.Articles) + .SingleAsync(a => a.Id == author.Id); + var articleOperationResult = data.Operations[0]; + + // author validation: sanity checks + Assert.NotNull(lastAuthor); + Assert.Equal(author.Name, lastAuthor.Name); + + //// article validation + Assert.Single(lastAuthor.Articles); + Assert.Equal(article.Name, lastAuthor.Articles[0].Name); + Assert.Equal(articleOperationResult.DataObject.Id, lastAuthor.Articles[0].StringId); + } + + [Fact] + public async Task Can_Create_Articles_With_Existing_Author() + { + + + // arrange + var context = GetService(); + var author = AuthorFactory.Get(); + context.Authors.Add(author); + await context.SaveChangesAsync(); + var expectedCount = _faker.Random.Int(1, 10); + var articles = ArticleFactory.Get(expectedCount); + + var content = new + { + operations = new List() + }; + + for (int i = 0; i < expectedCount; i++) + { + content.operations.Add( + new + { + op = "add", + data = new + { + type = "articles", + attributes = new + { + name = articles[i].Name + }, + relationships = new + { + author = new + { + data = new + { + type = "authors", + id = author.Id + } + } + } + } + } + ); + } + + // act + var (response, data) = await PatchAsync("api/bulk", content); + + // assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(expectedCount, data.Operations.Count); + + // author validation: sanity checks + var lastAuthor = context.Authors.Include(a => a.Articles).Single(a => a.Id == author.Id); + Assert.NotNull(lastAuthor); + Assert.Equal(author.Name, lastAuthor.Name); + + // articles validation + Assert.True(lastAuthor.Articles.Count == expectedCount); + for (int i = 0; i < expectedCount; i++) + { + var article = articles[i]; + Assert.NotNull(lastAuthor.Articles.FirstOrDefault(a => a.Name == article.Name)); + } + } + + [Fact] + public async Task Can_Create_Author_With_Article_Using_LocalId() + { + // arrange + var context = GetService(); + var author = AuthorFactory.Get(); + var article = ArticleFactory.Get(); + const string authorLocalId = "author-1"; + + var content = new + { + operations = new object[] { + new { + op = "add", + data = new { + lid = authorLocalId, + type = "authors", + attributes = new { + name = author.Name + }, + } + }, + new { + op = "add", + data = new { + type = "articles", + attributes = new { + name = article.Name + }, + relationships = new { + author = new { + data = new { + type = "authors", + lid = authorLocalId + } + } + } + } + } + } + }; + + // act + var (response, data) = await PatchAsync("api/bulk", content); + + // assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, data.Operations.Count); + + var authorOperationResult = data.Operations[0]; + var id = authorOperationResult.DataObject.Id; + var lastAuthor = await context.Authors + .Include(a => a.Articles) + .SingleAsync(a => a.StringId == id); + var articleOperationResult = data.Operations[1]; + + // author validation + Assert.Equal(authorLocalId, authorOperationResult.DataObject.LocalId); + Assert.Equal(author.Name, lastAuthor.Name); + + // article validation + Assert.Single(lastAuthor.Articles); + Assert.Equal(article.Name, lastAuthor.Articles[0].Name); + Assert.Equal(articleOperationResult.DataObject.Id, lastAuthor.Articles[0].StringId); + } + } +} diff --git a/test/OperationsExampleTests/Factories/ArticleFactory.cs b/test/OperationsExampleTests/Factories/ArticleFactory.cs new file mode 100644 index 0000000000..a03bc3fbea --- /dev/null +++ b/test/OperationsExampleTests/Factories/ArticleFactory.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using Bogus; +using JsonApiDotNetCoreExample.Models; + +namespace OperationsExampleTests.Factories +{ + public static class ArticleFactory + { + public static Article Get() + { + var faker = new Faker
(); + faker.RuleFor(m => m.Name, f => f.Lorem.Sentence()); + return faker.Generate(); + } + + public static List
Get(int count) + { + var articles = new List
(); + for (int i = 0; i < count; i++) + articles.Add(Get()); + + return articles; + } + } +} diff --git a/test/OperationsExampleTests/Factories/AuthorFactory.cs b/test/OperationsExampleTests/Factories/AuthorFactory.cs new file mode 100644 index 0000000000..e80b100a59 --- /dev/null +++ b/test/OperationsExampleTests/Factories/AuthorFactory.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using Bogus; +using JsonApiDotNetCoreExample.Models; + +namespace OperationsExampleTests.Factories +{ + public static class AuthorFactory + { + public static Author Get() + { + var faker = new Faker(); + faker.RuleFor(m => m.Name, f => f.Person.UserName); + return faker.Generate(); + } + + public static List Get(int count) + { + var authors = new List(); + for (int i = 0; i < count; i++) + authors.Add(Get()); + + return authors; + } + } +} diff --git a/test/OperationsExampleTests/Fixture.cs b/test/OperationsExampleTests/Fixture.cs new file mode 100644 index 0000000000..11621d36e4 --- /dev/null +++ b/test/OperationsExampleTests/Fixture.cs @@ -0,0 +1,60 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using JsonApiDotNetCoreExample.Data; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Newtonsoft.Json; +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] +namespace OperationsExampleTests +{ + public class Fixture : IDisposable + { + public Fixture() + { + var builder = new WebHostBuilder().UseStartup(); + Server = new TestServer(builder); + Client = Server.CreateClient(); + } + + public TestServer Server { get; private set; } + public HttpClient Client { get; } + + public void Dispose() + { + try + { + var context = GetService(); + context.Articles.RemoveRange(context.Articles); + context.Authors.RemoveRange(context.Authors); + context.SaveChanges(); + } // it is possible the test may try to do something that is an invalid db operation + // validation should be left up to the test, so we should not bomb the run in the + // disposal of that context + catch (Exception) { } + } + + public T GetService() => (T)Server.Host.Services.GetService(typeof(T)); + + public async Task PatchAsync(string route, object data) + { + var httpMethod = new HttpMethod("PATCH"); + var request = new HttpRequestMessage(httpMethod, route); + request.Content = new StringContent(JsonConvert.SerializeObject(data)); + request.Content.Headers.ContentLength = 1; + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + return await Client.SendAsync(request); + } + + public async Task<(HttpResponseMessage response, T data)> PatchAsync(string route, object data) + { + var response = await PatchAsync(route, data); + var json = await response.Content.ReadAsStringAsync(); + var obj = JsonConvert.DeserializeObject(json); + return (response, obj); + } + } +} diff --git a/test/OperationsExampleTests/Get/GetByIdTests.cs b/test/OperationsExampleTests/Get/GetByIdTests.cs new file mode 100644 index 0000000000..1056082895 --- /dev/null +++ b/test/OperationsExampleTests/Get/GetByIdTests.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Bogus; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Models.Operations; +using JsonApiDotNetCoreExample.Data; +using OperationsExampleTests.Factories; +using Xunit; + +namespace OperationsExampleTests +{ + public class GetTests : Fixture, IDisposable + { + private readonly Faker _faker = new Faker(); + + [Fact] + public async Task Can_Get_Author_By_Id() + { + // arrange + var context = GetService(); + var author = AuthorFactory.Get(); + context.Authors.Add(author); + context.SaveChanges(); + + var content = new + { + operations = new[] { + new Dictionary { + { "op", "get"}, + { "ref", new { type = "authors", id = author.StringId } } + } + } + }; + + // act + var (response, data) = await PatchAsync("api/bulk", content); + + // assert + Assert.NotNull(response); + Assert.NotNull(data); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Single(data.Operations); + Assert.Equal(author.Id.ToString(), data.Operations.Single().DataObject.Id); + } + + [Fact] + public async Task Get_Author_By_Id_Returns_404_If_NotFound() + { + // arrange + var authorId = _faker.Random.Int(max: 0).ToString(); + + var content = new + { + operations = new[] { + new Dictionary { + { "op", "get"}, + { "ref", new { type = "authors", id = authorId } } + } + } + }; + + // act + var (response, data) = await PatchAsync("api/bulk", content); + + // assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.NotNull(data); + Assert.Single(data.Errors); + Assert.True(data.Errors[0].Detail.Contains("authors"), "The error detail should contain the name of the entity that could not be found."); + Assert.True(data.Errors[0].Detail.Contains(authorId), "The error detail should contain the entity id that could not be found"); + Assert.True(data.Errors[0].Title.Contains("operation[0]"), "The error title should contain the operation identifier that failed"); + } + } +} diff --git a/test/OperationsExampleTests/Get/GetRelationshipTests.cs b/test/OperationsExampleTests/Get/GetRelationshipTests.cs new file mode 100644 index 0000000000..0aeef6f3ec --- /dev/null +++ b/test/OperationsExampleTests/Get/GetRelationshipTests.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Bogus; +using JsonApiDotNetCore.Models.Operations; +using JsonApiDotNetCoreExample.Data; +using OperationsExampleTests.Factories; +using Xunit; + +namespace OperationsExampleTests +{ + public class GetRelationshipTests : Fixture, IDisposable + { + private readonly Faker _faker = new Faker(); + + [Fact] + public async Task Can_Get_HasOne_Relationship() + { + // arrange + var context = GetService(); + var author = AuthorFactory.Get(); + var article = ArticleFactory.Get(); + article.Author = author; + context.Articles.Add(article); + context.SaveChanges(); + + var content = new + { + operations = new[] { + new Dictionary { + { "op", "get"}, + { "ref", new { type = "articles", id = article.StringId, relationship = "author" } } + } + } + }; + + // act + var (response, data) = await PatchAsync("api/bulk", content); + + // assert + Assert.NotNull(response); + Assert.NotNull(data); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Single(data.Operations); + var resourceObject = data.Operations.Single().DataObject; + Assert.Equal(author.Id.ToString(), resourceObject.Id); + Assert.Equal("authors", resourceObject.Type); + } + + [Fact] + public async Task Can_Get_HasMany_Relationship() + { + // arrange + var context = GetService(); + var author = AuthorFactory.Get(); + var article = ArticleFactory.Get(); + article.Author = author; + context.Articles.Add(article); + context.SaveChanges(); + + var content = new + { + operations = new[] { + new Dictionary { + { "op", "get"}, + { "ref", new { type = "authors", id = author.StringId, relationship = "articles" } } + } + } + }; + + // act + var (response, data) = await PatchAsync("api/bulk", content); + + // assert + Assert.NotNull(response); + Assert.NotNull(data); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Single(data.Operations); + + var resourceObject = data.Operations.Single().DataList.Single(); + Assert.Equal(article.Id.ToString(), resourceObject.Id); + Assert.Equal("articles", resourceObject.Type); + } + } +} diff --git a/test/OperationsExampleTests/Get/GetTests.cs b/test/OperationsExampleTests/Get/GetTests.cs new file mode 100644 index 0000000000..f0d3fdffd8 --- /dev/null +++ b/test/OperationsExampleTests/Get/GetTests.cs @@ -0,0 +1,73 @@ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Bogus; +using JsonApiDotNetCore.Models.Operations; +using JsonApiDotNetCoreExample.Data; +using OperationsExampleTests.Factories; +using Xunit; + +namespace OperationsExampleTests +{ + public class GetByIdTests : Fixture, IDisposable + { + private readonly Faker _faker = new Faker(); + + [Fact] + public async Task Can_Get_Authors() + { + // arrange + var expectedCount = _faker.Random.Int(1, 10); + var context = GetService(); + context.Articles.RemoveRange(context.Articles); + context.Authors.RemoveRange(context.Authors); + var authors = AuthorFactory.Get(expectedCount); + context.AddRange(authors); + context.SaveChanges(); + + var content = new + { + operations = new[] { + new Dictionary { + { "op", "get"}, + { "ref", new { type = "authors" } } + } + } + }; + + // act + var result = await PatchAsync("api/bulk", content); + + // assert + Assert.NotNull(result.response); + Assert.NotNull(result.data); + Assert.Equal(HttpStatusCode.OK, result.response.StatusCode); + Assert.Single(result.data.Operations); + Assert.Equal(expectedCount, result.data.Operations.Single().DataList.Count); + } + + [Fact] + public async Task Get_Non_Existent_Type_Returns_400() + { + // arrange + var content = new + { + operations = new[] { + new Dictionary { + { "op", "get"}, + { "ref", new { type = "non-existent-type" } } + } + } + }; + + // act + var result = await PatchAsync("api/bulk", content); + + // assert + Assert.Equal(HttpStatusCode.BadRequest, result.response.StatusCode); + } + } +} diff --git a/test/OperationsExampleTests/OperationsExampleTests.csproj b/test/OperationsExampleTests/OperationsExampleTests.csproj new file mode 100644 index 0000000000..f84b550354 --- /dev/null +++ b/test/OperationsExampleTests/OperationsExampleTests.csproj @@ -0,0 +1,24 @@ + + + $(NetCoreAppVersion) + false + OperationsExampleTests + + + + + + + + + + + + + + + + PreserveNewest + + + diff --git a/test/OperationsExampleTests/Remove/RemoveTests.cs b/test/OperationsExampleTests/Remove/RemoveTests.cs new file mode 100644 index 0000000000..70ffccbdbd --- /dev/null +++ b/test/OperationsExampleTests/Remove/RemoveTests.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Bogus; +using JsonApiDotNetCore.Models.Operations; +using JsonApiDotNetCoreExample.Data; +using OperationsExampleTests.Factories; +using Xunit; + +namespace OperationsExampleTests +{ + public class RemoveTests : Fixture + { + private readonly Faker _faker = new Faker(); + + [Fact] + public async Task Can_Remove_Author() + { + // arrange + var context = GetService(); + var author = AuthorFactory.Get(); + context.Authors.Add(author); + context.SaveChanges(); + + var content = new + { + operations = new[] { + new Dictionary { + { "op", "remove"}, + { "ref", new { type = "authors", id = author.StringId } } + } + } + }; + + // act + var (response, data) = await PatchAsync("api/bulk", content); + + // assert + Assert.NotNull(response); + Assert.NotNull(data); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Empty(data.Operations); + Assert.Null(context.Authors.SingleOrDefault(a => a.Id == author.Id)); + } + + [Fact] + public async Task Can_Remove_Authors() + { + // arrange + var count = _faker.Random.Int(1, 10); + var context = GetService(); + + var authors = AuthorFactory.Get(count); + + context.Authors.AddRange(authors); + context.SaveChanges(); + + var content = new + { + operations = new List() + }; + + for (int i = 0; i < count; i++) + content.operations.Add( + new Dictionary { + { "op", "remove"}, + { "ref", new { type = "authors", id = authors[i].StringId } } + } + ); + + // act + var (response, data) = await PatchAsync("api/bulk", content); + + // assert + Assert.NotNull(response); + Assert.NotNull(data); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Empty(data.Operations); + + for (int i = 0; i < count; i++) + Assert.Null(context.Authors.SingleOrDefault(a => a.Id == authors[i].Id)); + } + } +} diff --git a/test/OperationsExampleTests/TestStartup.cs b/test/OperationsExampleTests/TestStartup.cs new file mode 100644 index 0000000000..449c193177 --- /dev/null +++ b/test/OperationsExampleTests/TestStartup.cs @@ -0,0 +1,25 @@ +using JsonApiDotNetCore.Data; +using JsonApiDotNetCore.Services; +using JsonApiDotNetCoreExample.Data; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using OperationsExample; +using System; +using UnitTests; + +namespace OperationsExampleTests +{ + public class TestStartup : Startup + { + public TestStartup(IHostingEnvironment env) : base(env) + { } + + public override IServiceProvider ConfigureServices(IServiceCollection services) + { + base.ConfigureServices(services); + services.AddScoped(); + services.AddSingleton>(); + return services.BuildServiceProvider(); + } + } +} diff --git a/test/OperationsExampleTests/Transactions/TransactionFailureTests.cs b/test/OperationsExampleTests/Transactions/TransactionFailureTests.cs new file mode 100644 index 0000000000..191711651d --- /dev/null +++ b/test/OperationsExampleTests/Transactions/TransactionFailureTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Bogus; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCoreExample.Data; +using Microsoft.EntityFrameworkCore; +using OperationsExampleTests.Factories; +using Xunit; + +namespace OperationsExampleTests +{ + public class TransactionFailureTests : Fixture + { + private readonly Faker _faker = new Faker(); + + [Fact] + public async Task Cannot_Create_Author_If_Article_Creation_Fails() + { + // arrange + var context = GetService(); + var author = AuthorFactory.Get(); + var article = ArticleFactory.Get(); + + // do this so that the name is random enough for db validations + author.Name = Guid.NewGuid().ToString("N"); + article.Name = Guid.NewGuid().ToString("N"); + + var content = new + { + operations = new object[] { + new { + op = "add", + data = new { + type = "authors", + attributes = new { + name = author.Name + }, + } + }, + new { + op = "add", + data = new { + type = "articles", + attributes = new { + name = article.Name + }, + // by not including the author, the article creation will fail + // relationships = new { + // author = new { + // data = new { + // type = "authors", + // lid = authorLocalId + // } + // } + // } + } + } + } + }; + + // act + var (response, data) = await PatchAsync("api/bulk", content); + + // assert + Assert.NotNull(response); + // for now, it is up to application implementations to perform validation and + // provide the proper HTTP response code + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + Assert.Single(data.Errors); + Assert.Contains("operation[1] (add)", data.Errors[0].Title); + + var dbAuthors = await context.Authors.Where(a => a.Name == author.Name).ToListAsync(); + var dbArticles = await context.Articles.Where(a => a.Name == article.Name).ToListAsync(); + Assert.Empty(dbAuthors); + Assert.Empty(dbArticles); + } + } +} diff --git a/test/OperationsExampleTests/Update/UpdateTests.cs b/test/OperationsExampleTests/Update/UpdateTests.cs new file mode 100644 index 0000000000..c5d220b2f5 --- /dev/null +++ b/test/OperationsExampleTests/Update/UpdateTests.cs @@ -0,0 +1,112 @@ +using Bogus; +using JsonApiDotNetCore.Models.Operations; +using JsonApiDotNetCoreExample.Data; +using OperationsExampleTests.Factories; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Xunit; + +namespace OperationsExampleTests.Update +{ + public class UpdateTests : Fixture + { + private readonly Faker _faker = new Faker(); + + [Fact] + public async Task Can_Update_Author() + { + // arrange + var context = GetService(); + var author = AuthorFactory.Get(); + var updates = AuthorFactory.Get(); + context.Authors.Add(author); + context.SaveChanges(); + + var content = new + { + operations = new[] { + new Dictionary { + { "op", "update" }, + { "ref", new { + type = "authors", + id = author.Id, + } }, + { "data", new { + type = "authors", + id = author.Id, + attributes = new + { + name = updates.Name + } + } }, + } + } + }; + + // act + var (response, data) = await PatchAsync("api/bulk", content); + + // assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(data); + Assert.Single(data.Operations); + + var attrs = data.Operations.Single().DataObject.Attributes; + Assert.Equal(updates.Name, attrs["name"]); + } + + [Fact] + public async Task Can_Update_Authors() + { + // arrange + var count = _faker.Random.Int(1, 10); + var context = GetService(); + + var authors = AuthorFactory.Get(count); + var updates = AuthorFactory.Get(count); + + context.Authors.AddRange(authors); + context.SaveChanges(); + + var content = new + { + operations = new List() + }; + + for (int i = 0; i < count; i++) + content.operations.Add(new Dictionary { + { "op", "update" }, + { "ref", new { + type = "authors", + id = authors[i].Id, + } }, + { "data", new { + type = "authors", + id = authors[i].Id, + attributes = new + { + name = updates[i].Name + } + } }, + }); + + // act + var (response, data) = await PatchAsync("api/bulk", content); + + // assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.NotNull(data); + Assert.Equal(count, data.Operations.Count); + + for (int i = 0; i < count; i++) + { + var attrs = data.Operations[i].DataObject.Attributes; + Assert.Equal(updates[i].Name, attrs["name"]); + } + } + } +} diff --git a/test/OperationsExampleTests/appsettings.json b/test/OperationsExampleTests/appsettings.json new file mode 100644 index 0000000000..84f8cf4220 --- /dev/null +++ b/test/OperationsExampleTests/appsettings.json @@ -0,0 +1,15 @@ +{ + "Data": { + "DefaultConnection": + "Host=localhost;Port=5432;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=postgres" + }, + "Logging": { + "IncludeScopes": false, + "LogLevel": { + "Default": "Warning", + "System": "Warning", + "Microsoft": "Warning", + "JsonApiDotNetCore.Middleware.JsonApiExceptionFilter": "Critical" + } + } +} From 91d442927b47a8aa8842b689256681e399013e48 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 24 Nov 2020 00:45:24 +0100 Subject: [PATCH 002/123] Getting the code to build and tests to succeed. --- JsonApiDotNetCore.sln | 30 ++ .../Controllers/OperationsController.cs | 5 +- .../OperationsExample.csproj | 26 +- src/Examples/OperationsExample/Program.cs | 16 +- .../Properties/launchSettings.json | 51 +-- src/Examples/OperationsExample/Startup.cs | 54 ++-- src/Examples/OperationsExample/TestStartup.cs | 50 +++ .../OperationsExample/appsettings.json | 10 +- .../Configuration/IGenericServiceFactory.cs | 2 +- .../JsonApiApplicationBuilder.cs | 28 +- .../JsonApiOperationsController.cs | 6 +- .../Middleware/IJsonApiRequest.cs | 2 +- .../Middleware/JsonApiMiddleware.cs | 14 +- .../Models/Operations/Operation.cs | 8 +- .../Models/Operations/OperationCode.cs | 7 +- .../Models/Operations/ResourceReference.cs | 1 + .../Properties/AssemblyInfo.cs | 1 + .../Repositories/DbContextExtensions.cs | 83 +++++ .../Serialization/BaseDeserializer.cs | 4 +- .../Serialization/IJsonApiDeserializer.cs | 16 + .../Serialization/IOperationsDeserializer.cs | 11 - .../Serialization/JsonApiReader.cs | 9 +- .../Objects/ResourceIdentifierObject.cs | 12 +- .../Serialization/OperationsDeserializer.cs | 299 ------------------ .../Serialization/RequestDeserializer.cs | 25 ++ .../Serialization/ResponseSerializer.cs | 11 + .../Services/Operations/IOpProcessor.cs | 3 +- .../Operations/OperationProcessorResolver.cs | 62 ++-- .../Operations/OperationsProcessor.cs | 80 +++-- .../Processors/CreateOpProcessor.cs | 47 ++- .../Operations/Processors/GetOpProcessor.cs | 172 ---------- .../Processors/RemoveOpProcessor.cs | 42 ++- .../Processors/UpdateOpProcessor.cs | 64 ++-- test/OperationsExampleTests/Add/AddTests.cs | 91 +++--- .../Factories/ArticleFactory.cs | 2 +- .../Factories/AuthorFactory.cs | 2 +- test/OperationsExampleTests/Fixture.cs | 60 ---- .../Get/GetByIdTests.cs | 78 ----- .../Get/GetRelationshipTests.cs | 87 ----- test/OperationsExampleTests/Get/GetTests.cs | 73 ----- .../OperationsExampleTests.csproj | 24 +- .../Remove/RemoveTests.cs | 36 ++- test/OperationsExampleTests/TestFixture.cs | 73 +++++ test/OperationsExampleTests/TestStartup.cs | 25 -- .../Transactions/TransactionFailureTests.cs | 53 ++-- .../Update/UpdateTests.cs | 41 +-- .../WebHostCollection.cs | 10 + test/OperationsExampleTests/appsettings.json | 15 - test/OperationsExampleTests/xunit.runner.json | 4 + 49 files changed, 712 insertions(+), 1213 deletions(-) create mode 100644 src/Examples/OperationsExample/TestStartup.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/IOperationsDeserializer.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/OperationsDeserializer.cs delete mode 100644 src/JsonApiDotNetCore/Services/Operations/Processors/GetOpProcessor.cs delete mode 100644 test/OperationsExampleTests/Fixture.cs delete mode 100644 test/OperationsExampleTests/Get/GetByIdTests.cs delete mode 100644 test/OperationsExampleTests/Get/GetRelationshipTests.cs delete mode 100644 test/OperationsExampleTests/Get/GetTests.cs create mode 100644 test/OperationsExampleTests/TestFixture.cs delete mode 100644 test/OperationsExampleTests/TestStartup.cs create mode 100644 test/OperationsExampleTests/WebHostCollection.cs delete mode 100644 test/OperationsExampleTests/appsettings.json create mode 100644 test/OperationsExampleTests/xunit.runner.json diff --git a/JsonApiDotNetCore.sln b/JsonApiDotNetCore.sln index 0fc8aa3995..5c3021b916 100644 --- a/JsonApiDotNetCore.sln +++ b/JsonApiDotNetCore.sln @@ -45,6 +45,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MultiDbContextExample", "sr EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MultiDbContextTests", "test\MultiDbContextTests\MultiDbContextTests.csproj", "{EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OperationsExample", "src\Examples\OperationsExample\OperationsExample.csproj", "{86861146-9D5A-44A9-86CD-6E102278B094}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OperationsExampleTests", "test\OperationsExampleTests\OperationsExampleTests.csproj", "{D60E0F29-3410-493B-9428-E174E8B8F42E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -199,6 +203,30 @@ Global {EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA}.Release|x64.Build.0 = Release|Any CPU {EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA}.Release|x86.ActiveCfg = Release|Any CPU {EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA}.Release|x86.Build.0 = Release|Any CPU + {86861146-9D5A-44A9-86CD-6E102278B094}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {86861146-9D5A-44A9-86CD-6E102278B094}.Debug|Any CPU.Build.0 = Debug|Any CPU + {86861146-9D5A-44A9-86CD-6E102278B094}.Debug|x64.ActiveCfg = Debug|Any CPU + {86861146-9D5A-44A9-86CD-6E102278B094}.Debug|x64.Build.0 = Debug|Any CPU + {86861146-9D5A-44A9-86CD-6E102278B094}.Debug|x86.ActiveCfg = Debug|Any CPU + {86861146-9D5A-44A9-86CD-6E102278B094}.Debug|x86.Build.0 = Debug|Any CPU + {86861146-9D5A-44A9-86CD-6E102278B094}.Release|Any CPU.ActiveCfg = Release|Any CPU + {86861146-9D5A-44A9-86CD-6E102278B094}.Release|Any CPU.Build.0 = Release|Any CPU + {86861146-9D5A-44A9-86CD-6E102278B094}.Release|x64.ActiveCfg = Release|Any CPU + {86861146-9D5A-44A9-86CD-6E102278B094}.Release|x64.Build.0 = Release|Any CPU + {86861146-9D5A-44A9-86CD-6E102278B094}.Release|x86.ActiveCfg = Release|Any CPU + {86861146-9D5A-44A9-86CD-6E102278B094}.Release|x86.Build.0 = Release|Any CPU + {D60E0F29-3410-493B-9428-E174E8B8F42E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D60E0F29-3410-493B-9428-E174E8B8F42E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D60E0F29-3410-493B-9428-E174E8B8F42E}.Debug|x64.ActiveCfg = Debug|Any CPU + {D60E0F29-3410-493B-9428-E174E8B8F42E}.Debug|x64.Build.0 = Debug|Any CPU + {D60E0F29-3410-493B-9428-E174E8B8F42E}.Debug|x86.ActiveCfg = Debug|Any CPU + {D60E0F29-3410-493B-9428-E174E8B8F42E}.Debug|x86.Build.0 = Debug|Any CPU + {D60E0F29-3410-493B-9428-E174E8B8F42E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D60E0F29-3410-493B-9428-E174E8B8F42E}.Release|Any CPU.Build.0 = Release|Any CPU + {D60E0F29-3410-493B-9428-E174E8B8F42E}.Release|x64.ActiveCfg = Release|Any CPU + {D60E0F29-3410-493B-9428-E174E8B8F42E}.Release|x64.Build.0 = Release|Any CPU + {D60E0F29-3410-493B-9428-E174E8B8F42E}.Release|x86.ActiveCfg = Release|Any CPU + {D60E0F29-3410-493B-9428-E174E8B8F42E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -216,6 +244,8 @@ Global {21D27239-138D-4604-8E49-DCBE41BCE4C8} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF} {6CAFDDBE-00AB-4784-801B-AB419C3C3A26} = {026FBC6C-AF76-4568-9B87-EC73457899FD} {EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} + {86861146-9D5A-44A9-86CD-6E102278B094} = {026FBC6C-AF76-4568-9B87-EC73457899FD} + {D60E0F29-3410-493B-9428-E174E8B8F42E} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A2421882-8F0A-4905-928F-B550B192F9A4} diff --git a/src/Examples/OperationsExample/Controllers/OperationsController.cs b/src/Examples/OperationsExample/Controllers/OperationsController.cs index b0a3a38160..79a5e6ef48 100644 --- a/src/Examples/OperationsExample/Controllers/OperationsController.cs +++ b/src/Examples/OperationsExample/Controllers/OperationsController.cs @@ -1,4 +1,4 @@ -using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services.Operations; using Microsoft.AspNetCore.Mvc; @@ -9,6 +9,7 @@ public class OperationsController : JsonApiOperationsController { public OperationsController(IOperationsProcessor processor) : base(processor) - { } + { + } } } diff --git a/src/Examples/OperationsExample/OperationsExample.csproj b/src/Examples/OperationsExample/OperationsExample.csproj index efb3f2b3d4..129cc07142 100644 --- a/src/Examples/OperationsExample/OperationsExample.csproj +++ b/src/Examples/OperationsExample/OperationsExample.csproj @@ -1,30 +1,10 @@ - + $(NetCoreAppVersion) - true - OperationsExample - Exe - - + + - - - - - - - - - - - - - - - - - diff --git a/src/Examples/OperationsExample/Program.cs b/src/Examples/OperationsExample/Program.cs index 940a93148f..a6f4d5844e 100644 --- a/src/Examples/OperationsExample/Program.cs +++ b/src/Examples/OperationsExample/Program.cs @@ -1,15 +1,19 @@ -using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; namespace OperationsExample { public class Program { - public static void Main(string[] args) => BuildWebHost(args).Run(); + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } - public static IWebHost BuildWebHost(string[] args) => - WebHost.CreateDefaultBuilder(args) - .UseStartup() - .Build(); + public static IHostBuilder CreateHostBuilder(string[] args) + { + return Host.CreateDefaultBuilder(args) + .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup()); + } } } diff --git a/src/Examples/OperationsExample/Properties/launchSettings.json b/src/Examples/OperationsExample/Properties/launchSettings.json index b0d8e5bd4b..9eda7ff393 100644 --- a/src/Examples/OperationsExample/Properties/launchSettings.json +++ b/src/Examples/OperationsExample/Properties/launchSettings.json @@ -1,27 +1,30 @@ { - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:53656/", - "sslPort": 0 - } - }, - "profiles": { - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:14155", + "sslPort": 44355 + } }, - "OperationsExample": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "http://localhost:53657/" + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": false, + "launchUrl": "/operations", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Kestrel": { + "commandName": "Project", + "launchBrowser": false, + "launchUrl": "/operations", + "applicationUrl": "https://localhost:44355;http://localhost:14155", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } } - } -} \ No newline at end of file +} diff --git a/src/Examples/OperationsExample/Startup.cs b/src/Examples/OperationsExample/Startup.cs index a889ad85d6..6657fced72 100644 --- a/src/Examples/OperationsExample/Startup.cs +++ b/src/Examples/OperationsExample/Startup.cs @@ -1,55 +1,49 @@ using System; -using JsonApiDotNetCore.Extensions; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCoreExample.Data; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using Newtonsoft.Json; namespace OperationsExample { public class Startup { - public readonly IConfiguration Config; + private readonly string _connectionString; - public Startup(IHostingEnvironment env) + public Startup(IConfiguration configuration) { - var builder = new ConfigurationBuilder() - .SetBasePath(env.ContentRootPath) - .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) - .AddEnvironmentVariables(); - - Config = builder.Build(); + string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; + _connectionString = configuration["Data:DefaultConnection"].Replace("###", postgresPassword); } - public virtual IServiceProvider ConfigureServices(IServiceCollection services) + // This method gets called by the runtime. Use this method to add services to the container. + public virtual void ConfigureServices(IServiceCollection services) { - var loggerFactory = new LoggerFactory(); - loggerFactory.AddConsole(LogLevel.Warning); - - services.AddSingleton(loggerFactory); - - services.AddDbContext(options => options.UseNpgsql(GetDbConnectionString()), ServiceLifetime.Scoped); - - services.AddJsonApi(opt => opt.EnableOperations = true); - - return services.BuildServiceProvider(); + services.AddDbContext(options => + { + options.UseNpgsql(_connectionString, + postgresOptions => postgresOptions.SetPostgresVersion(new Version(9, 6))); + }); + + services.AddJsonApi(options => + { + options.IncludeExceptionStackTraceInErrors = true; + options.EnableOperations = true; + options.SerializerSettings.Formatting = Formatting.Indented; + }); } - public virtual void Configure( - IApplicationBuilder app, - IHostingEnvironment env, - ILoggerFactory loggerFactory, - AppDbContext context) + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + public void Configure(IApplicationBuilder app, AppDbContext context) { context.Database.EnsureCreated(); - loggerFactory.AddConsole(Config.GetSection("Logging")); + app.UseRouting(); app.UseJsonApi(); + app.UseEndpoints(endpoints => endpoints.MapControllers()); } - - public string GetDbConnectionString() => Config["Data:DefaultConnection"]; } } diff --git a/src/Examples/OperationsExample/TestStartup.cs b/src/Examples/OperationsExample/TestStartup.cs new file mode 100644 index 0000000000..fa4f589f52 --- /dev/null +++ b/src/Examples/OperationsExample/TestStartup.cs @@ -0,0 +1,50 @@ +using System; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace OperationsExample +{ + public class TestStartup : Startup + { + public TestStartup(IConfiguration configuration) + : base(configuration) + { + } + + public override void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + + base.ConfigureServices(services); + } + + /// + /// Advances the clock one second each time the current time is requested. + /// + private class TickingSystemClock : ISystemClock + { + private DateTimeOffset _utcNow; + + public DateTimeOffset UtcNow + { + get + { + var utcNow = _utcNow; + _utcNow = _utcNow.AddSeconds(1); + return utcNow; + } + } + + public TickingSystemClock() + : this(new DateTimeOffset(new DateTime(2000, 1, 1))) + { + } + + public TickingSystemClock(DateTimeOffset utcNow) + { + _utcNow = utcNow; + } + } + } +} diff --git a/src/Examples/OperationsExample/appsettings.json b/src/Examples/OperationsExample/appsettings.json index c468439079..866a5a6b6f 100644 --- a/src/Examples/OperationsExample/appsettings.json +++ b/src/Examples/OperationsExample/appsettings.json @@ -1,13 +1,13 @@ { "Data": { - "DefaultConnection": "Host=localhost;Port=5432;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=postgres" + "DefaultConnection": "Host=localhost;Port=5432;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=###" }, "Logging": { - "IncludeScopes": false, "LogLevel": { "Default": "Warning", - "System": "Warning", - "Microsoft": "Warning" + "Microsoft": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" } - } + }, + "AllowedHosts": "*" } diff --git a/src/JsonApiDotNetCore/Configuration/IGenericServiceFactory.cs b/src/JsonApiDotNetCore/Configuration/IGenericServiceFactory.cs index 70ac627218..29e5f18c02 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. + /// The typical use case would be for resolving operations processors. /// public interface IGenericServiceFactory { diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index e09a71e92f..ee23b65956 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -16,6 +16,8 @@ using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Building; using JsonApiDotNetCore.Services; +using JsonApiDotNetCore.Services.Operations; +using JsonApiDotNetCore.Services.Operations.Processors; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -132,9 +134,9 @@ public void ConfigureServiceContainer(ICollection dbContextTypes) } } - if (jsonApiOptions.EnableOperations) + if (_options.EnableOperations) { - AddOperationServices(services); + AddOperationServices(); } AddResourceLayer(); @@ -270,26 +272,22 @@ private void AddSerializationLayer() _services.AddScoped(typeof(ResponseSerializer<>)); _services.AddScoped(sp => sp.GetRequiredService().GetSerializer()); _services.AddScoped(); - services.AddScoped(); } - private static void AddOperationServices(IServiceCollection services) + private void AddOperationServices() { - services.AddScoped(); + _services.AddScoped(); - services.AddScoped(typeof(ICreateOpProcessor<>), typeof(CreateOpProcessor<>)); - services.AddScoped(typeof(ICreateOpProcessor<,>), typeof(CreateOpProcessor<,>)); + _services.AddScoped(typeof(ICreateOpProcessor<>), typeof(CreateOpProcessor<>)); + _services.AddScoped(typeof(ICreateOpProcessor<,>), typeof(CreateOpProcessor<,>)); - services.AddScoped(typeof(IGetOpProcessor<>), typeof(GetOpProcessor<>)); - services.AddScoped(typeof(IGetOpProcessor<,>), typeof(GetOpProcessor<,>)); + _services.AddScoped(typeof(IRemoveOpProcessor<>), typeof(RemoveOpProcessor<>)); + _services.AddScoped(typeof(IRemoveOpProcessor<,>), typeof(RemoveOpProcessor<,>)); - services.AddScoped(typeof(IRemoveOpProcessor<>), typeof(RemoveOpProcessor<>)); - services.AddScoped(typeof(IRemoveOpProcessor<,>), typeof(RemoveOpProcessor<,>)); + _services.AddScoped(typeof(IUpdateOpProcessor<>), typeof(UpdateOpProcessor<>)); + _services.AddScoped(typeof(IUpdateOpProcessor<,>), typeof(UpdateOpProcessor<,>)); - services.AddScoped(typeof(IUpdateOpProcessor<>), typeof(UpdateOpProcessor<>)); - services.AddScoped(typeof(IUpdateOpProcessor<,>), typeof(UpdateOpProcessor<,>)); - - services.AddScoped(); + _services.AddScoped(); } private void AddResourcesFromDbContext(DbContext dbContext, ResourceGraphBuilder builder) diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs index f6db9f0d06..9ec611e850 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs @@ -1,3 +1,4 @@ +using System.Threading; using System.Threading.Tasks; using JsonApiDotNetCore.Models.Operations; using JsonApiDotNetCore.Services.Operations; @@ -26,6 +27,7 @@ public JsonApiOperationsController(IOperationsProcessor operationsProcessor) /// /// A json:api operations request document /// + /// Propagates notification that request handling should be canceled. /// /// /// PATCH /api/bulk HTTP/1.1 @@ -48,11 +50,11 @@ public JsonApiOperationsController(IOperationsProcessor operationsProcessor) /// /// [HttpPatch] - public virtual async Task PatchAsync([FromBody] OperationsDocument doc) + public virtual async Task PatchOperationsAsync([FromBody] OperationsDocument doc, CancellationToken cancellationToken) { if (doc == null) return new StatusCodeResult(422); - var results = await _operationsProcessor.ProcessAsync(doc.Operations); + var results = await _operationsProcessor.ProcessAsync(doc.Operations, cancellationToken); return Ok(new OperationsDocument(results)); } diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs index b4ebe2d247..60182fb289 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs @@ -58,6 +58,6 @@ public interface IJsonApiRequest /// bool IsReadOnly { get; } - bool IsBulkRequest { get; set; } + bool IsBulkRequest { get; } } } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index 832982d4ab..a339a9af77 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -55,20 +55,24 @@ public async Task Invoke(HttpContext httpContext, return; } - _currentRequest.IsBulkRequest = PathIsBulk(); - SetupRequest((JsonApiRequest)request, primaryResourceContext, routeValues, options, resourceContextProvider, httpContext.Request); httpContext.RegisterJsonApiRequest(); } + else if (PathIsBulk(routeValues)) + { + ((JsonApiRequest)request).IsBulkRequest = true; + + httpContext.RegisterJsonApiRequest(); + } await _next(httpContext); } - private bool PathIsBulk() + private static bool PathIsBulk(RouteValueDictionary routeValues) { - var actionName = (string)_httpContext.GetRouteData().Values["action"]; - return actionName.ToLower().Contains("bulk"); + var actionName = (string)routeValues["action"]; + return actionName == "PatchOperations"; } private static ResourceContext CreatePrimaryResourceContext(RouteValueDictionary routeValues, diff --git a/src/JsonApiDotNetCore/Models/Operations/Operation.cs b/src/JsonApiDotNetCore/Models/Operations/Operation.cs index be6310c0da..11a0139234 100644 --- a/src/JsonApiDotNetCore/Models/Operations/Operation.cs +++ b/src/JsonApiDotNetCore/Models/Operations/Operation.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using JsonApiDotNetCore.Models.Links; +using JsonApiDotNetCore.Serialization.Objects; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Newtonsoft.Json.Linq; @@ -71,12 +71,12 @@ private void SetData(object data) public string GetResourceTypeName() { if (Ref != null) - return Ref.Type?.ToString(); + return Ref.Type; if (DataIsList) - return DataList[0].Type?.ToString(); + return DataList[0].Type; - return DataObject.Type?.ToString(); + return DataObject.Type; } } } diff --git a/src/JsonApiDotNetCore/Models/Operations/OperationCode.cs b/src/JsonApiDotNetCore/Models/Operations/OperationCode.cs index 6b6905cd59..34586744ca 100644 --- a/src/JsonApiDotNetCore/Models/Operations/OperationCode.cs +++ b/src/JsonApiDotNetCore/Models/Operations/OperationCode.cs @@ -6,9 +6,8 @@ namespace JsonApiDotNetCore.Models.Operations [JsonConverter(typeof(StringEnumConverter))] public enum OperationCode { - get = 1, - add = 2, - update = 3, - remove = 4 + add, + update, + remove } } diff --git a/src/JsonApiDotNetCore/Models/Operations/ResourceReference.cs b/src/JsonApiDotNetCore/Models/Operations/ResourceReference.cs index 5291d61a19..7d281156df 100644 --- a/src/JsonApiDotNetCore/Models/Operations/ResourceReference.cs +++ b/src/JsonApiDotNetCore/Models/Operations/ResourceReference.cs @@ -1,3 +1,4 @@ +using JsonApiDotNetCore.Serialization.Objects; using Newtonsoft.Json; namespace JsonApiDotNetCore.Models.Operations diff --git a/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs b/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs index 5d848911dd..e4340df841 100644 --- a/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs +++ b/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs @@ -4,3 +4,4 @@ [assembly:InternalsVisibleTo("JsonApiDotNetCoreExampleTests")] [assembly:InternalsVisibleTo("UnitTests")] [assembly:InternalsVisibleTo("DiscoveryTests")] +[assembly:InternalsVisibleTo("OperationsExampleTests")] diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index c12fd6eaeb..2eeccf858f 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -1,7 +1,11 @@ using System; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using JsonApiDotNetCore.Resources; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; namespace JsonApiDotNetCore.Repositories { @@ -42,5 +46,84 @@ public static object GetTrackedIdentifiable(this DbContext dbContext, IIdentifia return entityEntry?.Entity; } + + /// + /// Gets the current transaction or creates a new one. + /// If a transaction already exists, commit, rollback and dispose + /// will not be called. It is assumed the creator of the original + /// transaction should be responsible for disposal. + /// + /// + /// + /// + /// using(var transaction = _context.GetCurrentOrCreateTransaction()) + /// { + /// // perform multiple operations on the context and then save... + /// _context.SaveChanges(); + /// } + /// + /// + public static async Task GetCurrentOrCreateTransactionAsync(this DbContext context) + => await SafeTransactionProxy.GetOrCreateAsync(context.Database); + } + + /// + /// Gets the current transaction or creates a new one. + /// If a transaction already exists, commit, rollback and dispose + /// will not be called. It is assumed the creator of the original + /// transaction should be responsible for disposal. + /// + internal class SafeTransactionProxy : IDbContextTransaction + { + private readonly bool _shouldExecute; + private readonly IDbContextTransaction _transaction; + + private SafeTransactionProxy(IDbContextTransaction transaction, bool shouldExecute) + { + _transaction = transaction; + _shouldExecute = shouldExecute; + } + + public static async Task GetOrCreateAsync(DatabaseFacade databaseFacade) + => (databaseFacade.CurrentTransaction != null) + ? new SafeTransactionProxy(databaseFacade.CurrentTransaction, shouldExecute: false) + : new SafeTransactionProxy(await databaseFacade.BeginTransactionAsync(), shouldExecute: true); + + /// + public Guid TransactionId => _transaction.TransactionId; + + /// + public void Commit() => Proxy(t => t.Commit()); + + /// + public Task CommitAsync(CancellationToken cancellationToken) => Proxy(t => t.CommitAsync(cancellationToken)); + + /// + public void Rollback() => Proxy(t => t.Rollback()); + + /// + public Task RollbackAsync(CancellationToken cancellationToken) => Proxy(t => t.RollbackAsync(cancellationToken)); + + /// + public void Dispose() => Proxy(t => t.Dispose()); + + public ValueTask DisposeAsync() + { + return Proxy(t => t.DisposeAsync()); + } + + private void Proxy(Action func) + { + if(_shouldExecute) + func(_transaction); + } + + private TResult Proxy(Func func) + { + if(_shouldExecute) + return func(_transaction); + + return default; + } } } diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index 67e4f41c71..58141d306b 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -133,7 +133,7 @@ protected virtual IIdentifiable SetRelationships(IIdentifiable resource, IDictio return resource; } - private JToken LoadJToken(string body) + protected JToken LoadJToken(string body) { JToken jToken; using (JsonReader jsonReader = new JsonTextReader(new StringReader(body))) @@ -150,7 +150,7 @@ private JToken LoadJToken(string body) /// and sets its attributes and relationships. /// /// The parsed resource. - private IIdentifiable ParseResourceObject(ResourceObject data) + protected IIdentifiable ParseResourceObject(ResourceObject data) { AssertHasType(data, null); diff --git a/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs index eeebb47d95..db1094aa51 100644 --- a/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs @@ -1,3 +1,5 @@ +using JsonApiDotNetCore.Models.Operations; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.Serialization @@ -14,5 +16,19 @@ public interface IJsonApiDeserializer /// The JSON to be deserialized. /// The resources constructed from the content. object Deserialize(string body); + + /// + /// Deserializes JSON into a and constructs entities + /// from . + /// + /// The JSON to be deserialized + /// The operations document constructed from the content + object DeserializeOperationsRequestDocument(string body); + + /// + /// Creates an instance of the referenced type in + /// and sets its attributes and relationships + /// + IIdentifiable CreateResourceFromObject(ResourceObject data); } } diff --git a/src/JsonApiDotNetCore/Serialization/IOperationsDeserializer.cs b/src/JsonApiDotNetCore/Serialization/IOperationsDeserializer.cs deleted file mode 100644 index bc6c2f7726..0000000000 --- a/src/JsonApiDotNetCore/Serialization/IOperationsDeserializer.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Models; - -namespace JsonApiDotNetCore.Serialization.Deserializer -{ - public interface IOperationsDeserializer - { - object Deserialize(string body); - object DocumentToObject(ResourceObject data, List included = null); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index 4192c33a88..7af2399adf 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs @@ -22,14 +22,12 @@ namespace JsonApiDotNetCore.Serialization /// public class JsonApiReader : IJsonApiReader { - private readonly IOperationsDeserializer _operationsDeserializer; private readonly IJsonApiDeserializer _deserializer; private readonly IJsonApiRequest _request; private readonly IResourceContextProvider _resourceContextProvider; private readonly TraceLogWriter _traceWriter; public JsonApiReader(IJsonApiDeserializer deserializer, - IOperationsDeserializer operationsDeserializer, IJsonApiRequest request, IResourceContextProvider resourceContextProvider, ILoggerFactory loggerFactory) @@ -37,7 +35,6 @@ public JsonApiReader(IJsonApiDeserializer deserializer, if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); _deserializer = deserializer ?? throw new ArgumentNullException(nameof(deserializer)); - _operationsDeserializer = operationsDeserializer ?? throw new ArgumentNullException(nameof(operationsDeserializer)); _request = request ?? throw new ArgumentNullException(nameof(request)); _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); _traceWriter = new TraceLogWriter(loggerFactory); @@ -50,10 +47,10 @@ public async Task ReadAsync(InputFormatterContext context) string body = await GetRequestBodyAsync(context.HttpContext.Request.Body); - if (_currentRequest.IsBulkRequest) + if (_request.IsBulkRequest) { - var operations = _operationsDeserializer.Deserialize(body); - return InputFormatterResult.SuccessAsync(operations); + var operations = _deserializer.DeserializeOperationsRequestDocument(body); + return await InputFormatterResult.SuccessAsync(operations); } string url = context.HttpContext.Request.GetEncodedUrl(); diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs index d5479678ca..b71ab5045d 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs @@ -4,19 +4,15 @@ 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; } - [JsonIgnore] - //[JsonProperty("lid")] + [JsonProperty("lid", NullValueHandling = NullValueHandling.Ignore, Order = -2)] public string LocalId { get; set; } - public override string ToString() - { - return $"(type: {Type}, id: {Id})"; - } + public override string ToString() => $"(type: {Type}, id: {Id}, lid: {LocalId})"; } } diff --git a/src/JsonApiDotNetCore/Serialization/OperationsDeserializer.cs b/src/JsonApiDotNetCore/Serialization/OperationsDeserializer.cs deleted file mode 100644 index 07cb4489c5..0000000000 --- a/src/JsonApiDotNetCore/Serialization/OperationsDeserializer.cs +++ /dev/null @@ -1,299 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reflection; -using JsonApiDotNetCore.Extensions; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.Operations; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace JsonApiDotNetCore.Serialization.Deserializer -{ - /// - /// Legacy document parser to be used for Bulk requests. - /// Will probably remove this for v4. - /// - public class OperationsDeserializer : IOperationsDeserializer - { - private readonly ITargetedFields _targetedFieldsManager; - private readonly IResourceGraph _resourceGraph; - private readonly JsonSerializer _jsonSerializer; - - public OperationsDeserializer(ITargetedFields updatedFieldsManager, - IResourceGraph resourceGraph) - { - _targetedFieldsManager = updatedFieldsManager; - _resourceGraph = resourceGraph; - } - - public object Deserialize(string requestBody) - { - try - { - JToken bodyJToken; - using (JsonReader jsonReader = new JsonTextReader(new StringReader(requestBody))) - { - jsonReader.DateParseHandling = DateParseHandling.None; - bodyJToken = JToken.Load(jsonReader); - } - var operations = JsonConvert.DeserializeObject(requestBody); - if (operations == null) - throw new JsonApiException(400, "Failed to deserialize operations request."); - - return operations; - } - catch (JsonApiException) - { - throw; - } - catch (Exception e) - { - throw new JsonApiException(400, "Failed to deserialize request body", e); - } - } - - public object DocumentToObject(ResourceObject data, List included = null) - { - if (data == null) - throw new JsonApiException(422, "Failed to deserialize document as json:api."); - - var contextEntity = _resourceGraph.GetContextEntity(data.Type?.ToString()); - if (contextEntity == null) - { - throw new JsonApiException(400, - message: $"This API does not contain a json:api resource named '{data.Type}'.", - detail: "This resource is not registered on the ResourceGraph. " - + "If you are using Entity Framework, make sure the DbSet matches the expected resource name. " - + "If you have manually registered the resource, check that the call to AddResource correctly sets the public name."); - } - - - var entity = Activator.CreateInstance(contextEntity.EntityType); - - entity = SetEntityAttributes(entity, contextEntity, data.Attributes); - entity = SetRelationships(entity, contextEntity, data.Relationships, included); - - var identifiableEntity = (IIdentifiable)entity; - - if (data.Id != null) - identifiableEntity.StringId = data.Id?.ToString(); - - return identifiableEntity; - } - - private object SetEntityAttributes( - object entity, ContextEntity contextEntity, Dictionary attributeValues) - { - if (attributeValues == null || attributeValues.Count == 0) - return entity; - - foreach (var attr in contextEntity.Attributes) - { - if (attributeValues.TryGetValue(attr.PublicAttributeName, out object newValue)) - { - if (attr.IsImmutable) - continue; - var convertedValue = ConvertAttrValue(newValue, attr.PropertyInfo.PropertyType); - attr.SetValue(entity, convertedValue); - _targetedFieldsManager.Attributes.Add(attr); - } - } - - return entity; - } - - private object ConvertAttrValue(object newValue, Type targetType) - { - if (newValue is JContainer jObject) - return DeserializeComplexType(jObject, targetType); - - var convertedValue = TypeHelper.ConvertType(newValue, targetType); - return convertedValue; - } - - private object DeserializeComplexType(JContainer obj, Type targetType) - { - return obj.ToObject(targetType, _jsonSerializer); - } - - private object SetRelationships( - object entity, - ContextEntity contextEntity, - Dictionary relationships, - List included = null) - { - if (relationships == null || relationships.Count == 0) - return entity; - - var entityProperties = entity.GetType().GetProperties(); - - foreach (var attr in contextEntity.Relationships) - { - entity = attr.IsHasOne - ? SetHasOneRelationship(entity, entityProperties, (HasOneAttribute)attr, contextEntity, relationships, included) - : SetHasManyRelationship(entity, entityProperties, (HasManyAttribute)attr, contextEntity, relationships, included); - } - - return entity; - } - - private object SetHasOneRelationship(object entity, - PropertyInfo[] entityProperties, - HasOneAttribute attr, - ContextEntity contextEntity, - Dictionary relationships, - List included = null) - { - var relationshipName = attr.PublicRelationshipName; - - if (relationships.TryGetValue(relationshipName, out RelationshipEntry relationshipData) == false) - return entity; - - var rio = (ResourceIdentifierObject)relationshipData.Data; - - var foreignKey = attr.IdentifiablePropertyName; - var foreignKeyProperty = entityProperties.FirstOrDefault(p => p.Name == foreignKey); - if (foreignKeyProperty == null && rio == null) - return entity; - - SetHasOneForeignKeyValue(entity, attr, foreignKeyProperty, rio); - SetHasOneNavigationPropertyValue(entity, attr, rio, included); - - // recursive call ... - if (included != null) - { - var navigationPropertyValue = attr.GetValue(entity); - - var resourceGraphEntity = _resourceGraph.GetContextEntity(attr.DependentType); - if (navigationPropertyValue != null && resourceGraphEntity != null) - - { - var includedResource = included.SingleOrDefault(r => r.Type == rio.Type && r.Id == rio.Id); - if (includedResource != null) - SetRelationships(navigationPropertyValue, resourceGraphEntity, includedResource.Relationships, included); - } - } - - return entity; - } - - private void SetHasOneForeignKeyValue(object entity, HasOneAttribute hasOneAttr, PropertyInfo foreignKeyProperty, ResourceIdentifierObject rio) - { - var foreignKeyPropertyValue = rio?.Id ?? null; - if (foreignKeyProperty != null) - { - // in the case of the HasOne independent side of the relationship, we should still create the shell entity on the other side - // we should not actually require the resource to have a foreign key (be the dependent side of the relationship) - - // e.g. PATCH /articles - // {... { "relationships":{ "Owner": { "data": null } } } } - bool foreignKeyPropertyIsNullableType = Nullable.GetUnderlyingType(foreignKeyProperty.PropertyType) != null - || foreignKeyProperty.PropertyType == typeof(string); - if (rio == null && !foreignKeyPropertyIsNullableType) - throw new JsonApiException(400, $"Cannot set required relationship identifier '{hasOneAttr.IdentifiablePropertyName}' to null because it is a non-nullable type."); - - var convertedValue = TypeHelper.ConvertType(foreignKeyPropertyValue, foreignKeyProperty.PropertyType); - /// todo: as a part of the process of decoupling JADNC (specifically - /// through the decoupling IJsonApiContext), we now no longer need to - /// store the updated relationship values in this property. For now - /// just assigning null as value, will remove this property later as a whole. - /// see #512 - if (convertedValue == null) _targetedFieldsManager.Relationships.Add(hasOneAttr); - } - } - - /// - /// Sets the value of the navigation property for the related resource. - /// If the resource has been included, all attributes will be set. - /// If the resource has not been included, only the id will be set. - /// - private void SetHasOneNavigationPropertyValue(object entity, HasOneAttribute hasOneAttr, ResourceIdentifierObject rio, List included) - { - // if the resource identifier is null, there should be no reason to instantiate an instance - if (rio != null && rio.Id != null) - { - // we have now set the FK property on the resource, now we need to check to see if the - // related entity was included in the payload and update its attributes - var includedRelationshipObject = GetIncludedRelationship(rio, included, hasOneAttr); - if (includedRelationshipObject != null) - hasOneAttr.SetValue(entity, includedRelationshipObject); - - /// todo: as a part of the process of decoupling JADNC (specifically - /// through the decoupling IJsonApiContext), we now no longer need to - /// store the updated relationship values in this property. For now - /// just assigning null as value, will remove this property later as a whole. - /// see #512 - _targetedFieldsManager.Relationships.Add(hasOneAttr); - } - } - - private object SetHasManyRelationship(object entity, - PropertyInfo[] entityProperties, - HasManyAttribute attr, - ContextEntity contextEntity, - Dictionary relationships, - List included = null) - { - var relationshipName = attr.PublicRelationshipName; - - if (relationships.TryGetValue(relationshipName, out RelationshipEntry relationshipData)) - { - if (relationshipData.IsManyData == false) - return entity; - - var relatedResources = relationshipData.ManyData.Select(r => - { - var instance = GetIncludedRelationship(r, included, attr); - return instance; - }); - - var convertedCollection = TypeHelper.ConvertCollection(relatedResources, attr.DependentType); - - attr.SetValue(entity, convertedCollection); - _targetedFieldsManager.Relationships.Add(attr); - } - - return entity; - } - - private IIdentifiable GetIncludedRelationship(ResourceIdentifierObject relatedResourceIdentifier, List includedResources, RelationshipAttribute relationshipAttr) - { - // at this point we can be sure the relationshipAttr.Type is IIdentifiable because we were able to successfully build the ResourceGraph - var relatedInstance = relationshipAttr.DependentType.New(); - relatedInstance.StringId = relatedResourceIdentifier.Id; - - // can't provide any more data other than the rio since it is not contained in the included section - if (includedResources == null || includedResources.Count == 0) - return relatedInstance; - - var includedResource = GetLinkedResource(relatedResourceIdentifier, includedResources); - if (includedResource == null) - return relatedInstance; - - var contextEntity = _resourceGraph.GetContextEntity(relationshipAttr.DependentType); - if (contextEntity == null) - throw new JsonApiException(400, $"Included type '{relationshipAttr.DependentType}' is not a registered json:api resource."); - - SetEntityAttributes(relatedInstance, contextEntity, includedResource.Attributes); - - return relatedInstance; - } - - private ResourceObject GetLinkedResource(ResourceIdentifierObject relatedResourceIdentifier, List includedResources) - { - try - { - return includedResources.SingleOrDefault(r => r.Type == relatedResourceIdentifier.Type && r.Id == relatedResourceIdentifier.Id); - } - catch (InvalidOperationException e) - { - throw new JsonApiException(400, $"A compound document MUST NOT include more than one resource object for each type and id pair." - + $"The duplicate pair was '{relatedResourceIdentifier.Type}, {relatedResourceIdentifier.Id}'", e); - } - } - } -} \ No newline at end of file diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index d4f420f412..79b3a18037 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -1,12 +1,16 @@ using System; using System.Linq; +using System.Net; using System.Net.Http; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Models.Operations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Http; +using Newtonsoft.Json.Linq; namespace JsonApiDotNetCore.Serialization { @@ -49,6 +53,27 @@ public object Deserialize(string body) return instance; } + public object DeserializeOperationsRequestDocument(string body) + { + JToken bodyToken = LoadJToken(body); + var document = bodyToken.ToObject(); + + if (document?.Operations == null || !document.Operations.Any()) + { + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "Failed to deserialize operations request." + }); + } + + return document; + } + + public IIdentifiable CreateResourceFromObject(ResourceObject data) + { + return ParseResourceObject(data); + } + private void AssertResourceIdIsNotTargeted() { if (!_request.IsReadOnly && _targetedFields.Attributes.Any(attribute => attribute.Property.Name == nameof(Identifiable.Id))) diff --git a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs index 2b30768320..61f6d4867b 100644 --- a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs @@ -3,6 +3,7 @@ using System.Linq; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Models.Operations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Building; @@ -66,9 +67,19 @@ public string Serialize(object data) return SerializeErrorDocument(errorDocument); } + if (data is OperationsDocument operationsDocument) + { + return SerializeOperationsDocument(operationsDocument); + } + throw new InvalidOperationException("Data being returned must be errors or resources."); } + private string SerializeOperationsDocument(OperationsDocument operationsDocument) + { + return SerializeObject(operationsDocument, _options.SerializerSettings); + } + private string SerializeErrorDocument(ErrorDocument errorDocument) { return SerializeObject(errorDocument, _options.SerializerSettings, serializer => { serializer.ApplyErrorSettings(); }); diff --git a/src/JsonApiDotNetCore/Services/Operations/IOpProcessor.cs b/src/JsonApiDotNetCore/Services/Operations/IOpProcessor.cs index 0a2d30397c..2a0840898d 100644 --- a/src/JsonApiDotNetCore/Services/Operations/IOpProcessor.cs +++ b/src/JsonApiDotNetCore/Services/Operations/IOpProcessor.cs @@ -1,3 +1,4 @@ +using System.Threading; using System.Threading.Tasks; using JsonApiDotNetCore.Models.Operations; @@ -5,6 +6,6 @@ namespace JsonApiDotNetCore.Services.Operations { public interface IOpProcessor { - Task ProcessAsync(Operation operation); + Task ProcessAsync(Operation operation, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/Services/Operations/OperationProcessorResolver.cs b/src/JsonApiDotNetCore/Services/Operations/OperationProcessorResolver.cs index 41dc43a1f7..904f622129 100644 --- a/src/JsonApiDotNetCore/Services/Operations/OperationProcessorResolver.cs +++ b/src/JsonApiDotNetCore/Services/Operations/OperationProcessorResolver.cs @@ -1,7 +1,8 @@ -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Internal.Generics; +using System.Net; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Models.Operations; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCore.Services.Operations.Processors; namespace JsonApiDotNetCore.Services.Operations @@ -16,11 +17,6 @@ public interface IOperationProcessorResolver /// IOpProcessor LocateCreateService(Operation operation); - /// - /// Locates the correct - /// - IOpProcessor LocateGetService(Operation operation); - /// /// Locates the correct /// @@ -35,16 +31,16 @@ public interface IOperationProcessorResolver /// public class OperationProcessorResolver : IOperationProcessorResolver { - private readonly IGenericProcessorFactory _processorFactory; - private readonly IContextEntityProvider _provider; + private readonly IGenericServiceFactory _genericServiceFactory; + private readonly IResourceContextProvider _resourceContextProvider; /// public OperationProcessorResolver( - IGenericProcessorFactory processorFactory, - IContextEntityProvider provider) + IGenericServiceFactory genericServiceFactory, + IResourceContextProvider resourceContextProvider) { - _processorFactory = processorFactory; - _provider = provider; + _genericServiceFactory = genericServiceFactory; + _resourceContextProvider = resourceContextProvider; } /// @@ -54,22 +50,8 @@ public IOpProcessor LocateCreateService(Operation operation) var contextEntity = GetResourceMetadata(resource); - var processor = _processorFactory.GetProcessor( - typeof(ICreateOpProcessor<,>), contextEntity.EntityType, contextEntity.IdentityType - ); - - return processor; - } - - /// - public IOpProcessor LocateGetService(Operation operation) - { - var resource = operation.GetResourceTypeName(); - - var contextEntity = GetResourceMetadata(resource); - - var processor = _processorFactory.GetProcessor( - typeof(IGetOpProcessor<,>), contextEntity.EntityType, contextEntity.IdentityType + var processor = _genericServiceFactory.Get( + typeof(ICreateOpProcessor<,>), contextEntity.ResourceType, contextEntity.IdentityType ); return processor; @@ -82,8 +64,8 @@ public IOpProcessor LocateRemoveService(Operation operation) var contextEntity = GetResourceMetadata(resource); - var processor = _processorFactory.GetProcessor( - typeof(IRemoveOpProcessor<,>), contextEntity.EntityType, contextEntity.IdentityType + var processor = _genericServiceFactory.Get( + typeof(IRemoveOpProcessor<,>), contextEntity.ResourceType, contextEntity.IdentityType ); return processor; @@ -96,18 +78,22 @@ public IOpProcessor LocateUpdateService(Operation operation) var contextEntity = GetResourceMetadata(resource); - var processor = _processorFactory.GetProcessor( - typeof(IUpdateOpProcessor<,>), contextEntity.EntityType, contextEntity.IdentityType + var processor = _genericServiceFactory.Get( + typeof(IUpdateOpProcessor<,>), contextEntity.ResourceType, contextEntity.IdentityType ); return processor; } - private ContextEntity GetResourceMetadata(string resourceName) + private ResourceContext GetResourceMetadata(string resourceName) { - var contextEntity = _provider.GetContextEntity(resourceName); - if(contextEntity == null) - throw new JsonApiException(400, $"This API does not expose a resource of type '{resourceName}'."); + var contextEntity = _resourceContextProvider.GetResourceContext(resourceName); + if (contextEntity == null) + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "Unsupported resource type.", + Detail = $"This API does not expose a resource of type '{resourceName}'." + }); return contextEntity; } diff --git a/src/JsonApiDotNetCore/Services/Operations/OperationsProcessor.cs b/src/JsonApiDotNetCore/Services/Operations/OperationsProcessor.cs index fcbff7c613..3ca480a011 100644 --- a/src/JsonApiDotNetCore/Services/Operations/OperationsProcessor.cs +++ b/src/JsonApiDotNetCore/Services/Operations/OperationsProcessor.cs @@ -1,77 +1,89 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; +using System.Threading; using System.Threading.Tasks; -using JsonApiDotNetCore.Data; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Managers.Contracts; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Models.Operations; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; namespace JsonApiDotNetCore.Services.Operations { public interface IOperationsProcessor { - Task> ProcessAsync(List inputOps); + Task> ProcessAsync(List inputOps, CancellationToken cancellationToken); } public class OperationsProcessor : IOperationsProcessor { private readonly IOperationProcessorResolver _processorResolver; private readonly DbContext _dbContext; - private readonly ICurrentRequest _currentRequest; + private readonly IJsonApiRequest _request; + private readonly ITargetedFields _targetedFields; private readonly IResourceGraph _resourceGraph; public OperationsProcessor( IOperationProcessorResolver processorResolver, IDbContextResolver dbContextResolver, - ICurrentRequest currentRequest, + IJsonApiRequest request, + ITargetedFields targetedFields, IResourceGraph resourceGraph) { _processorResolver = processorResolver; _dbContext = dbContextResolver.GetContext(); - _currentRequest = currentRequest; + _request = request; + _targetedFields = targetedFields; _resourceGraph = resourceGraph; } - public async Task> ProcessAsync(List inputOps) + public async Task> ProcessAsync(List inputOps, CancellationToken cancellationToken) { var outputOps = new List(); var opIndex = 0; OperationCode? lastAttemptedOperation = null; // used for error messages only - using (var transaction = await _dbContext.Database.BeginTransactionAsync()) + using (var transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken)) { try { foreach (var op in inputOps) { - //_jsonApiContext.BeginOperation(); - lastAttemptedOperation = op.Op; - await ProcessOperation(op, outputOps); + await ProcessOperation(op, outputOps, cancellationToken); opIndex++; } - transaction.Commit(); + await transaction.CommitAsync(cancellationToken); return outputOps; } catch (JsonApiException e) { - transaction.Rollback(); - throw new JsonApiException(e.GetStatusCode(), $"Transaction failed on operation[{opIndex}] ({lastAttemptedOperation}).", e); + await transaction.RollbackAsync(cancellationToken); + throw new JsonApiException(new Error(e.Error.StatusCode) + { + Title = "Transaction failed on operation.", + Detail = $"Transaction failed on operation[{opIndex}] ({lastAttemptedOperation})." + }, e); } catch (Exception e) { - transaction.Rollback(); - throw new JsonApiException(500, $"Transaction failed on operation[{opIndex}] ({lastAttemptedOperation}) for an unexpected reason.", e); + await transaction.RollbackAsync(cancellationToken); + throw new JsonApiException(new Error(HttpStatusCode.InternalServerError) + { + Title = "Transaction failed on operation.", + Detail = $"Transaction failed on operation[{opIndex}] ({lastAttemptedOperation}) for an unexpected reason." + }, e); } } } - private async Task ProcessOperation(Operation op, List outputOps) + private async Task ProcessOperation(Operation op, List outputOps, CancellationToken cancellationToken) { ReplaceLocalIdsInResourceObject(op.DataObject, outputOps); ReplaceLocalIdsInRef(op.Ref, outputOps); @@ -81,14 +93,18 @@ private async Task ProcessOperation(Operation op, List outputOps) { type = op.DataObject.Type; } - else if (op.Op == OperationCode.get || op.Op == OperationCode.remove) + else if (op.Op == OperationCode.remove) { type = op.Ref.Type; } - _currentRequest.SetRequestResource(_resourceGraph.GetEntityFromControllerName(type)); + + ((JsonApiRequest)_request).PrimaryResource = _resourceGraph.GetResourceContext(type); + + _targetedFields.Attributes.Clear(); + _targetedFields.Relationships.Clear(); var processor = GetOperationsProcessor(op); - var resultOp = await processor.ProcessAsync(op); + var resultOp = await processor.ProcessAsync(op, cancellationToken); if (resultOp != null) outputOps.Add(resultOp); @@ -141,7 +157,15 @@ private void ReplaceLocalIdsInRef(ResourceReference resourceRef, List private string GetIdFromLocalId(List outputOps, string localId) { var referencedOp = outputOps.FirstOrDefault(o => o.DataObject.LocalId == localId); - if (referencedOp == null) throw new JsonApiException(400, $"Could not locate lid '{localId}' in document."); + if (referencedOp == null) + { + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "Could not locate lid in document.", + Detail = $"Could not locate lid '{localId}' in document." + }); + } + return referencedOp.DataObject.Id; } @@ -151,14 +175,16 @@ private IOpProcessor GetOperationsProcessor(Operation op) { case OperationCode.add: return _processorResolver.LocateCreateService(op); - case OperationCode.get: - return _processorResolver.LocateGetService(op); case OperationCode.remove: return _processorResolver.LocateRemoveService(op); case OperationCode.update: return _processorResolver.LocateUpdateService(op); default: - throw new JsonApiException(400, $"'{op.Op}' is not a valid operation code"); + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "Invalid operation code.", + Detail = $"'{op.Op}' is not a valid operation code." + }); } } } diff --git a/src/JsonApiDotNetCore/Services/Operations/Processors/CreateOpProcessor.cs b/src/JsonApiDotNetCore/Services/Operations/Processors/CreateOpProcessor.cs index 4eb5c65961..c6cbea6db9 100644 --- a/src/JsonApiDotNetCore/Services/Operations/Processors/CreateOpProcessor.cs +++ b/src/JsonApiDotNetCore/Services/Operations/Processors/CreateOpProcessor.cs @@ -1,10 +1,10 @@ +using System.Threading; using System.Threading.Tasks; -using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Models.Operations; -using JsonApiDotNetCore.Serialization.Deserializer; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Building; namespace JsonApiDotNetCore.Services.Operations.Processors { @@ -22,59 +22,52 @@ public class CreateOpProcessor { public CreateOpProcessor( ICreateService service, - IOperationsDeserializer deserializer, - IBaseDocumentBuilder documentBuilder, + IJsonApiDeserializer deserializer, + IResourceObjectBuilder resourceObjectBuilder, IResourceGraph resourceGraph - ) : base(service, deserializer, documentBuilder, resourceGraph) + ) : base(service, deserializer, resourceObjectBuilder, resourceGraph) { } } - public interface IBaseDocumentBuilder - { - ResourceObject GetData(ContextEntity contextEntity, IIdentifiable singleResource); - } - public class CreateOpProcessor : ICreateOpProcessor where T : class, IIdentifiable { private readonly ICreateService _service; - private readonly IOperationsDeserializer _deserializer; - private readonly IBaseDocumentBuilder _documentBuilder; + private readonly IJsonApiDeserializer _deserializer; + private readonly IResourceObjectBuilder _resourceObjectBuilder; private readonly IResourceGraph _resourceGraph; public CreateOpProcessor( ICreateService service, - IOperationsDeserializer deserializer, - IBaseDocumentBuilder documentBuilder, + IJsonApiDeserializer deserializer, + IResourceObjectBuilder resourceObjectBuilder, IResourceGraph resourceGraph) { _service = service; _deserializer = deserializer; - _documentBuilder = documentBuilder; + _resourceObjectBuilder = resourceObjectBuilder; _resourceGraph = resourceGraph; } - public async Task ProcessAsync(Operation operation) + public async Task ProcessAsync(Operation operation, CancellationToken cancellationToken) { - - var model = (T)_deserializer.DocumentToObject(operation.DataObject); - var result = await _service.CreateAsync(model); + var model = (T)_deserializer.CreateResourceFromObject(operation.DataObject); + var result = await _service.CreateAsync(model, cancellationToken); var operationResult = new Operation { Op = OperationCode.add }; - operationResult.Data = _documentBuilder.GetData( - _resourceGraph.GetContextEntity(operation.GetResourceTypeName()), - result); + ResourceContext resourceContext = _resourceGraph.GetResourceContext(operation.DataObject.Type); + + operationResult.Data = _resourceObjectBuilder.Build(result, resourceContext.Attributes, resourceContext.Relationships); // we need to persist the original request localId so that subsequent operations // can locate the result of this operation by its localId operationResult.DataObject.LocalId = operation.DataObject.LocalId; - return null; - //return operationResult; + return operationResult; } } } diff --git a/src/JsonApiDotNetCore/Services/Operations/Processors/GetOpProcessor.cs b/src/JsonApiDotNetCore/Services/Operations/Processors/GetOpProcessor.cs deleted file mode 100644 index ec2144bb77..0000000000 --- a/src/JsonApiDotNetCore/Services/Operations/Processors/GetOpProcessor.cs +++ /dev/null @@ -1,172 +0,0 @@ -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; -using JsonApiDotNetCore.Models.Operations; -using JsonApiDotNetCore.Serialization.Deserializer; - -namespace JsonApiDotNetCore.Services.Operations.Processors -{ - /// - /// Handles all "" operations - /// - /// The resource type - public interface IGetOpProcessor : IGetOpProcessor - where T : class, IIdentifiable - { } - - /// - /// Handles all "" operations - /// - /// The resource type - /// The resource identifier type - public interface IGetOpProcessor : IOpProcessor - where T : class, IIdentifiable - { } - - /// - public class GetOpProcessor : GetOpProcessor, IGetOpProcessor - where T : class, IIdentifiable - { - /// - public GetOpProcessor( - IGetAllService getAll, - IGetByIdService getById, - IGetRelationshipService getRelationship, - IOperationsDeserializer deserializer, - IBaseDocumentBuilder documentBuilder, - IResourceGraph resourceGraph - ) : base(getAll, getById, getRelationship, deserializer, documentBuilder, resourceGraph) - { } - } - - /// - public class GetOpProcessor : IGetOpProcessor - where T : class, IIdentifiable - { - private readonly IGetAllService _getAll; - private readonly IGetByIdService _getById; - private readonly IGetRelationshipService _getRelationship; - private readonly IOperationsDeserializer _deserializer; - private readonly IBaseDocumentBuilder _documentBuilder; - private readonly IResourceGraph _resourceGraph; - - /// - public GetOpProcessor( - IGetAllService getAll, - IGetByIdService getById, - IGetRelationshipService getRelationship, - IOperationsDeserializer deserializer, - IBaseDocumentBuilder documentBuilder, - IResourceGraph resourceGraph) - { - _getAll = getAll; - _getById = getById; - _getRelationship = getRelationship; - _deserializer = deserializer; - _documentBuilder = documentBuilder; - _resourceGraph = resourceGraph; - } - - /// - public async Task ProcessAsync(Operation operation) - { - var operationResult = new Operation - { - Op = OperationCode.get - }; - - operationResult.Data = string.IsNullOrWhiteSpace(operation.Ref.Id) - ? await GetAllAsync(operation) - : string.IsNullOrWhiteSpace(operation.Ref.Relationship) - ? await GetByIdAsync(operation) - : await GetRelationshipAsync(operation); - - return operationResult; - } - - private async Task GetAllAsync(Operation operation) - { - var result = await _getAll.GetAsync(); - - var operations = new List(); - foreach (var resource in result) - { - var doc = _documentBuilder.GetData( - _resourceGraph.GetContextEntity(operation.GetResourceTypeName()), - resource); - operations.Add(doc); - } - - return operations; - } - - private async Task GetByIdAsync(Operation operation) - { - var id = GetReferenceId(operation); - var result = await _getById.GetAsync(id); - - // this is a bit ugly but we need to bomb the entire transaction if the entity cannot be found - // in the future it would probably be better to return a result status along with the doc to - // avoid throwing exceptions on 4xx errors. - // consider response type (status, document) - if (result == null) - throw new JsonApiException(404, $"Could not find '{operation.Ref.Type}' record with id '{operation.Ref.Id}'"); - - var doc = _documentBuilder.GetData( - _resourceGraph.GetContextEntity(operation.GetResourceTypeName()), - result); - - return doc; - } - - private async Task GetRelationshipAsync(Operation operation) - { - var id = GetReferenceId(operation); - var result = await _getRelationship.GetRelationshipAsync(id, operation.Ref.Relationship); - - // TODO: need a better way to get the ContextEntity from a relationship name - // when no generic parameter is available - var relationshipType = _resourceGraph.GetContextEntity(operation.GetResourceTypeName()) - .Relationships.Single(r => r.Is(operation.Ref.Relationship)).DependentType; - - var relatedContextEntity = _resourceGraph.GetContextEntity(relationshipType); - - if (result == null) - return null; - - if (result is IIdentifiable singleResource) - return GetData(relatedContextEntity, singleResource); - - if (result is IEnumerable multipleResults) - return GetData(relatedContextEntity, multipleResults); - - throw new JsonApiException(500, - $"An unexpected type was returned from '{_getRelationship.GetType()}.{nameof(IGetRelationshipService.GetRelationshipAsync)}'.", - detail: $"Type '{result.GetType()} does not implement {nameof(IIdentifiable)} nor {nameof(IEnumerable)}'"); - } - - private ResourceObject GetData(ContextEntity contextEntity, IIdentifiable singleResource) - { - return _documentBuilder.GetData(contextEntity, singleResource); - } - - private List GetData(ContextEntity contextEntity, IEnumerable multipleResults) - { - var resources = new List(); - foreach (var singleResult in multipleResults) - { - if (singleResult is IIdentifiable resource) - resources.Add(_documentBuilder.GetData(contextEntity, resource)); - } - - return resources; - } - - private TId GetReferenceId(Operation operation) => TypeHelper.ConvertType(operation.Ref.Id); - } -} diff --git a/src/JsonApiDotNetCore/Services/Operations/Processors/RemoveOpProcessor.cs b/src/JsonApiDotNetCore/Services/Operations/Processors/RemoveOpProcessor.cs index c1c80d21fa..41f14fd074 100644 --- a/src/JsonApiDotNetCore/Services/Operations/Processors/RemoveOpProcessor.cs +++ b/src/JsonApiDotNetCore/Services/Operations/Processors/RemoveOpProcessor.cs @@ -1,10 +1,13 @@ +using System.Net; +using System.Threading; using System.Threading.Tasks; -using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Models.Operations; -using JsonApiDotNetCore.Serialization.Deserializer; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Building; +using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.Services.Operations.Processors { @@ -21,10 +24,10 @@ public class RemoveOpProcessor : RemoveOpProcessor, IRemoveOpProcesso { public RemoveOpProcessor( IDeleteService service, - IOperationsDeserializer deserializer, - IBaseDocumentBuilder documentBuilder, + IJsonApiDeserializer deserializer, + IResourceObjectBuilder resourceObjectBuilder, IResourceGraph resourceGraph - ) : base(service, deserializer, documentBuilder, resourceGraph) + ) : base(service, deserializer, resourceObjectBuilder, resourceGraph) { } } @@ -32,30 +35,35 @@ public class RemoveOpProcessor : IRemoveOpProcessor where T : class, IIdentifiable { private readonly IDeleteService _service; - private readonly IOperationsDeserializer _deserializer; - private readonly IBaseDocumentBuilder _documentBuilder; + private readonly IJsonApiDeserializer _deserializer; + private readonly IResourceObjectBuilder _resourceObjectBuilder; private readonly IResourceGraph _resourceGraph; public RemoveOpProcessor( IDeleteService service, - IOperationsDeserializer deserializer, - IBaseDocumentBuilder documentBuilder, + IJsonApiDeserializer deserializer, + IResourceObjectBuilder resourceObjectBuilder, IResourceGraph resourceGraph) { _service = service; _deserializer = deserializer; - _documentBuilder = documentBuilder; + _resourceObjectBuilder = resourceObjectBuilder; _resourceGraph = resourceGraph; } - public async Task ProcessAsync(Operation operation) + public async Task ProcessAsync(Operation operation, CancellationToken cancellationToken) { var stringId = operation.Ref?.Id?.ToString(); if (string.IsNullOrWhiteSpace(stringId)) - throw new JsonApiException(400, "The ref.id parameter is required for remove operations"); + { + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "The ref.id element is required for remove operations." + }); + } - var id = TypeHelper.ConvertType(stringId); - var result = await _service.DeleteAsync(id); + var id = (TId)TypeHelper.ConvertType(stringId, typeof(TId)); + await _service.DeleteAsync(id, cancellationToken); return null; } diff --git a/src/JsonApiDotNetCore/Services/Operations/Processors/UpdateOpProcessor.cs b/src/JsonApiDotNetCore/Services/Operations/Processors/UpdateOpProcessor.cs index 37d22d14f1..a463377fc1 100644 --- a/src/JsonApiDotNetCore/Services/Operations/Processors/UpdateOpProcessor.cs +++ b/src/JsonApiDotNetCore/Services/Operations/Processors/UpdateOpProcessor.cs @@ -1,10 +1,13 @@ +using System.Net; +using System.Threading; using System.Threading.Tasks; -using JsonApiDotNetCore.Builders; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Internal.Contracts; -using JsonApiDotNetCore.Models; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Models.Operations; -using JsonApiDotNetCore.Serialization.Deserializer; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Building; +using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.Services.Operations.Processors { @@ -21,10 +24,10 @@ public class UpdateOpProcessor : UpdateOpProcessor, IUpdateOpProcesso { public UpdateOpProcessor( IUpdateService service, - IOperationsDeserializer deserializer, - IBaseDocumentBuilder documentBuilder, + IJsonApiDeserializer deserializer, + IResourceObjectBuilder resourceObjectBuilder, IResourceGraph resourceGraph - ) : base(service, deserializer, documentBuilder, resourceGraph) + ) : base(service, deserializer, resourceObjectBuilder, resourceGraph) { } } @@ -32,42 +35,49 @@ public class UpdateOpProcessor : IUpdateOpProcessor where T : class, IIdentifiable { private readonly IUpdateService _service; - private readonly IOperationsDeserializer _deserializer; - private readonly IBaseDocumentBuilder _documentBuilder; + private readonly IJsonApiDeserializer _deserializer; + private readonly IResourceObjectBuilder _resourceObjectBuilder; private readonly IResourceGraph _resourceGraph; public UpdateOpProcessor( IUpdateService service, - IOperationsDeserializer deserializer, - IBaseDocumentBuilder documentBuilder, + IJsonApiDeserializer deserializer, + IResourceObjectBuilder resourceObjectBuilder, IResourceGraph resourceGraph) { _service = service; _deserializer = deserializer; - _documentBuilder = documentBuilder; + _resourceObjectBuilder = resourceObjectBuilder; _resourceGraph = resourceGraph; } - public async Task ProcessAsync(Operation operation) + public async Task ProcessAsync(Operation operation, CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(operation?.DataObject?.Id?.ToString())) - throw new JsonApiException(400, "The data.id parameter is required for replace operations"); + if (string.IsNullOrWhiteSpace(operation?.DataObject?.Id)) + { + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "The data.id element is required for replace operations." + }); + } - //var model = (T)_deserializer.DocumentToObject(operation.DataObject); - T model = null; // TODO + var model = (T)_deserializer.CreateResourceFromObject(operation.DataObject); - var result = await _service.UpdateAsync(model.Id, model); - if (result == null) - throw new JsonApiException(404, $"Could not find an instance of '{operation.DataObject.Type}' with id {operation.DataObject.Id}"); + var result = await _service.UpdateAsync(model.Id, model, cancellationToken); - var operationResult = new Operation - { - Op = OperationCode.update - }; + ResourceObject data = null; - operationResult.Data = _documentBuilder.GetData(_resourceGraph.GetContextEntity(operation.GetResourceTypeName()), result); + if (result != null) + { + ResourceContext resourceContext = _resourceGraph.GetResourceContext(operation.DataObject.Type); + data = _resourceObjectBuilder.Build(result, resourceContext.Attributes, resourceContext.Relationships); + } - return operationResult; + return new Operation + { + Op = OperationCode.update, + Data = data + }; } } } diff --git a/test/OperationsExampleTests/Add/AddTests.cs b/test/OperationsExampleTests/Add/AddTests.cs index 0deaaa6a82..ca948a3b6b 100644 --- a/test/OperationsExampleTests/Add/AddTests.cs +++ b/test/OperationsExampleTests/Add/AddTests.cs @@ -4,22 +4,28 @@ using System.Threading.Tasks; using Bogus; using JsonApiDotNetCore.Models.Operations; -using JsonApiDotNetCoreExample.Data; using Microsoft.EntityFrameworkCore; +using OperationsExample; using OperationsExampleTests.Factories; using Xunit; -namespace OperationsExampleTests +namespace OperationsExampleTests.Add { - public class AddTests : Fixture + [Collection("WebHostCollection")] + public class AddTests { + private readonly TestFixture _fixture; private readonly Faker _faker = new Faker(); + public AddTests(TestFixture fixture) + { + _fixture = fixture; + } + [Fact] public async Task Can_Create_Author() { // arrange - var context = GetService(); var author = AuthorFactory.Get(); var content = new { @@ -29,7 +35,7 @@ public async Task Can_Create_Author() data = new { type = "authors", attributes = new { - name = author.Name + firstName = author.FirstName } } } @@ -37,15 +43,15 @@ public async Task Can_Create_Author() }; // act - var (response, data) = await PatchAsync("api/bulk", content); + var (response, data) = await _fixture.PatchAsync("api/bulk", content); // assert Assert.NotNull(response); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - var id = data.Operations.Single().DataObject.Id; - var lastAuthor = await context.Authors.SingleAsync(a => a.StringId == id); - Assert.Equal(author.Name, lastAuthor.Name); + var id = int.Parse(data.Operations.Single().DataObject.Id); + var lastAuthor = await _fixture.Context.AuthorDifferentDbContextName.SingleAsync(a => a.Id == id); + Assert.Equal(author.FirstName, lastAuthor.FirstName); } [Fact] @@ -53,7 +59,6 @@ public async Task Can_Create_Authors() { // arrange var expectedCount = _faker.Random.Int(1, 10); - var context = GetService(); var authors = AuthorFactory.Get(expectedCount); var content = new { @@ -71,7 +76,7 @@ public async Task Can_Create_Authors() type = "authors", attributes = new { - name = authors[i].Name + firstName = authors[i].FirstName } } } @@ -79,18 +84,18 @@ public async Task Can_Create_Authors() } // act - var (response, data) = await PatchAsync("api/bulk", content); + var (response, data) = await _fixture.PatchAsync("api/bulk", content); // assert Assert.NotNull(response); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); Assert.Equal(expectedCount, data.Operations.Count); for (int i = 0; i < expectedCount; i++) { var dataObject = data.Operations[i].DataObject; - var author = context.Authors.Single(a => a.StringId == dataObject.Id); - Assert.Equal(authors[i].Name, author.Name); + var author = _fixture.Context.AuthorDifferentDbContextName.Single(a => a.Id == int.Parse(dataObject.Id)); + Assert.Equal(authors[i].FirstName, author.FirstName); } } @@ -98,11 +103,11 @@ public async Task Can_Create_Authors() public async Task Can_Create_Article_With_Existing_Author() { // arrange - var context = GetService(); + var context = _fixture.Context; var author = AuthorFactory.Get(); var article = ArticleFactory.Get(); - context.Authors.Add(author); + context.AuthorDifferentDbContextName.Add(author); await context.SaveChangesAsync(); @@ -116,7 +121,7 @@ public async Task Can_Create_Article_With_Existing_Author() data = new { type = "articles", attributes = new { - name = article.Name + caption = article.Caption }, relationships = new { author = new { @@ -132,39 +137,36 @@ public async Task Can_Create_Article_With_Existing_Author() }; // act - var (response, data) = await PatchAsync("api/bulk", content); + var (response, data) = await _fixture.PatchAsync("api/bulk", content); // assert Assert.NotNull(response); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); Assert.Single(data.Operations); - var lastAuthor = await context.Authors + var lastAuthor = await context.AuthorDifferentDbContextName .Include(a => a.Articles) .SingleAsync(a => a.Id == author.Id); var articleOperationResult = data.Operations[0]; // author validation: sanity checks Assert.NotNull(lastAuthor); - Assert.Equal(author.Name, lastAuthor.Name); + Assert.Equal(author.FirstName, lastAuthor.FirstName); //// article validation Assert.Single(lastAuthor.Articles); - Assert.Equal(article.Name, lastAuthor.Articles[0].Name); + Assert.Equal(article.Caption, lastAuthor.Articles[0].Caption); Assert.Equal(articleOperationResult.DataObject.Id, lastAuthor.Articles[0].StringId); } [Fact] public async Task Can_Create_Articles_With_Existing_Author() { - - // arrange - var context = GetService(); var author = AuthorFactory.Get(); - context.Authors.Add(author); - await context.SaveChangesAsync(); + _fixture.Context.AuthorDifferentDbContextName.Add(author); + await _fixture.Context.SaveChangesAsync(); var expectedCount = _faker.Random.Int(1, 10); var articles = ArticleFactory.Get(expectedCount); @@ -184,7 +186,7 @@ public async Task Can_Create_Articles_With_Existing_Author() type = "articles", attributes = new { - name = articles[i].Name + caption = articles[i].Caption }, relationships = new { @@ -203,24 +205,24 @@ public async Task Can_Create_Articles_With_Existing_Author() } // act - var (response, data) = await PatchAsync("api/bulk", content); + var (response, data) = await _fixture.PatchAsync("api/bulk", content); // assert Assert.NotNull(response); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); Assert.Equal(expectedCount, data.Operations.Count); // author validation: sanity checks - var lastAuthor = context.Authors.Include(a => a.Articles).Single(a => a.Id == author.Id); + var lastAuthor = _fixture.Context.AuthorDifferentDbContextName.Include(a => a.Articles).Single(a => a.Id == author.Id); Assert.NotNull(lastAuthor); - Assert.Equal(author.Name, lastAuthor.Name); + Assert.Equal(author.FirstName, lastAuthor.FirstName); // articles validation Assert.True(lastAuthor.Articles.Count == expectedCount); for (int i = 0; i < expectedCount; i++) { var article = articles[i]; - Assert.NotNull(lastAuthor.Articles.FirstOrDefault(a => a.Name == article.Name)); + Assert.NotNull(lastAuthor.Articles.FirstOrDefault(a => a.Caption == article.Caption)); } } @@ -228,7 +230,6 @@ public async Task Can_Create_Articles_With_Existing_Author() public async Task Can_Create_Author_With_Article_Using_LocalId() { // arrange - var context = GetService(); var author = AuthorFactory.Get(); var article = ArticleFactory.Get(); const string authorLocalId = "author-1"; @@ -242,7 +243,7 @@ public async Task Can_Create_Author_With_Article_Using_LocalId() lid = authorLocalId, type = "authors", attributes = new { - name = author.Name + firstName = author.FirstName }, } }, @@ -251,7 +252,7 @@ public async Task Can_Create_Author_With_Article_Using_LocalId() data = new { type = "articles", attributes = new { - name = article.Name + caption = article.Caption }, relationships = new { author = new { @@ -267,27 +268,27 @@ public async Task Can_Create_Author_With_Article_Using_LocalId() }; // act - var (response, data) = await PatchAsync("api/bulk", content); + var (response, data) = await _fixture.PatchAsync("api/bulk", content); // assert Assert.NotNull(response); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); Assert.Equal(2, data.Operations.Count); var authorOperationResult = data.Operations[0]; - var id = authorOperationResult.DataObject.Id; - var lastAuthor = await context.Authors + var id = int.Parse(authorOperationResult.DataObject.Id); + var lastAuthor = await _fixture.Context.AuthorDifferentDbContextName .Include(a => a.Articles) - .SingleAsync(a => a.StringId == id); + .SingleAsync(a => a.Id == id); var articleOperationResult = data.Operations[1]; // author validation Assert.Equal(authorLocalId, authorOperationResult.DataObject.LocalId); - Assert.Equal(author.Name, lastAuthor.Name); + Assert.Equal(author.FirstName, lastAuthor.FirstName); // article validation Assert.Single(lastAuthor.Articles); - Assert.Equal(article.Name, lastAuthor.Articles[0].Name); + Assert.Equal(article.Caption, lastAuthor.Articles[0].Caption); Assert.Equal(articleOperationResult.DataObject.Id, lastAuthor.Articles[0].StringId); } } diff --git a/test/OperationsExampleTests/Factories/ArticleFactory.cs b/test/OperationsExampleTests/Factories/ArticleFactory.cs index a03bc3fbea..b53ad8e972 100644 --- a/test/OperationsExampleTests/Factories/ArticleFactory.cs +++ b/test/OperationsExampleTests/Factories/ArticleFactory.cs @@ -9,7 +9,7 @@ public static class ArticleFactory public static Article Get() { var faker = new Faker
(); - faker.RuleFor(m => m.Name, f => f.Lorem.Sentence()); + faker.RuleFor(m => m.Caption, f => f.Lorem.Sentence()); return faker.Generate(); } diff --git a/test/OperationsExampleTests/Factories/AuthorFactory.cs b/test/OperationsExampleTests/Factories/AuthorFactory.cs index e80b100a59..f8b5ce91cf 100644 --- a/test/OperationsExampleTests/Factories/AuthorFactory.cs +++ b/test/OperationsExampleTests/Factories/AuthorFactory.cs @@ -9,7 +9,7 @@ public static class AuthorFactory public static Author Get() { var faker = new Faker(); - faker.RuleFor(m => m.Name, f => f.Person.UserName); + faker.RuleFor(m => m.FirstName, f => f.Person.FirstName); return faker.Generate(); } diff --git a/test/OperationsExampleTests/Fixture.cs b/test/OperationsExampleTests/Fixture.cs deleted file mode 100644 index 11621d36e4..0000000000 --- a/test/OperationsExampleTests/Fixture.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using JsonApiDotNetCoreExample.Data; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Newtonsoft.Json; -using Xunit; - -[assembly: CollectionBehavior(DisableTestParallelization = true)] -namespace OperationsExampleTests -{ - public class Fixture : IDisposable - { - public Fixture() - { - var builder = new WebHostBuilder().UseStartup(); - Server = new TestServer(builder); - Client = Server.CreateClient(); - } - - public TestServer Server { get; private set; } - public HttpClient Client { get; } - - public void Dispose() - { - try - { - var context = GetService(); - context.Articles.RemoveRange(context.Articles); - context.Authors.RemoveRange(context.Authors); - context.SaveChanges(); - } // it is possible the test may try to do something that is an invalid db operation - // validation should be left up to the test, so we should not bomb the run in the - // disposal of that context - catch (Exception) { } - } - - public T GetService() => (T)Server.Host.Services.GetService(typeof(T)); - - public async Task PatchAsync(string route, object data) - { - var httpMethod = new HttpMethod("PATCH"); - var request = new HttpRequestMessage(httpMethod, route); - request.Content = new StringContent(JsonConvert.SerializeObject(data)); - request.Content.Headers.ContentLength = 1; - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); - return await Client.SendAsync(request); - } - - public async Task<(HttpResponseMessage response, T data)> PatchAsync(string route, object data) - { - var response = await PatchAsync(route, data); - var json = await response.Content.ReadAsStringAsync(); - var obj = JsonConvert.DeserializeObject(json); - return (response, obj); - } - } -} diff --git a/test/OperationsExampleTests/Get/GetByIdTests.cs b/test/OperationsExampleTests/Get/GetByIdTests.cs deleted file mode 100644 index 1056082895..0000000000 --- a/test/OperationsExampleTests/Get/GetByIdTests.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCore.Models.Operations; -using JsonApiDotNetCoreExample.Data; -using OperationsExampleTests.Factories; -using Xunit; - -namespace OperationsExampleTests -{ - public class GetTests : Fixture, IDisposable - { - private readonly Faker _faker = new Faker(); - - [Fact] - public async Task Can_Get_Author_By_Id() - { - // arrange - var context = GetService(); - var author = AuthorFactory.Get(); - context.Authors.Add(author); - context.SaveChanges(); - - var content = new - { - operations = new[] { - new Dictionary { - { "op", "get"}, - { "ref", new { type = "authors", id = author.StringId } } - } - } - }; - - // act - var (response, data) = await PatchAsync("api/bulk", content); - - // assert - Assert.NotNull(response); - Assert.NotNull(data); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Single(data.Operations); - Assert.Equal(author.Id.ToString(), data.Operations.Single().DataObject.Id); - } - - [Fact] - public async Task Get_Author_By_Id_Returns_404_If_NotFound() - { - // arrange - var authorId = _faker.Random.Int(max: 0).ToString(); - - var content = new - { - operations = new[] { - new Dictionary { - { "op", "get"}, - { "ref", new { type = "authors", id = authorId } } - } - } - }; - - // act - var (response, data) = await PatchAsync("api/bulk", content); - - // assert - Assert.NotNull(response); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - Assert.NotNull(data); - Assert.Single(data.Errors); - Assert.True(data.Errors[0].Detail.Contains("authors"), "The error detail should contain the name of the entity that could not be found."); - Assert.True(data.Errors[0].Detail.Contains(authorId), "The error detail should contain the entity id that could not be found"); - Assert.True(data.Errors[0].Title.Contains("operation[0]"), "The error title should contain the operation identifier that failed"); - } - } -} diff --git a/test/OperationsExampleTests/Get/GetRelationshipTests.cs b/test/OperationsExampleTests/Get/GetRelationshipTests.cs deleted file mode 100644 index 0aeef6f3ec..0000000000 --- a/test/OperationsExampleTests/Get/GetRelationshipTests.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Models.Operations; -using JsonApiDotNetCoreExample.Data; -using OperationsExampleTests.Factories; -using Xunit; - -namespace OperationsExampleTests -{ - public class GetRelationshipTests : Fixture, IDisposable - { - private readonly Faker _faker = new Faker(); - - [Fact] - public async Task Can_Get_HasOne_Relationship() - { - // arrange - var context = GetService(); - var author = AuthorFactory.Get(); - var article = ArticleFactory.Get(); - article.Author = author; - context.Articles.Add(article); - context.SaveChanges(); - - var content = new - { - operations = new[] { - new Dictionary { - { "op", "get"}, - { "ref", new { type = "articles", id = article.StringId, relationship = "author" } } - } - } - }; - - // act - var (response, data) = await PatchAsync("api/bulk", content); - - // assert - Assert.NotNull(response); - Assert.NotNull(data); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Single(data.Operations); - var resourceObject = data.Operations.Single().DataObject; - Assert.Equal(author.Id.ToString(), resourceObject.Id); - Assert.Equal("authors", resourceObject.Type); - } - - [Fact] - public async Task Can_Get_HasMany_Relationship() - { - // arrange - var context = GetService(); - var author = AuthorFactory.Get(); - var article = ArticleFactory.Get(); - article.Author = author; - context.Articles.Add(article); - context.SaveChanges(); - - var content = new - { - operations = new[] { - new Dictionary { - { "op", "get"}, - { "ref", new { type = "authors", id = author.StringId, relationship = "articles" } } - } - } - }; - - // act - var (response, data) = await PatchAsync("api/bulk", content); - - // assert - Assert.NotNull(response); - Assert.NotNull(data); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Single(data.Operations); - - var resourceObject = data.Operations.Single().DataList.Single(); - Assert.Equal(article.Id.ToString(), resourceObject.Id); - Assert.Equal("articles", resourceObject.Type); - } - } -} diff --git a/test/OperationsExampleTests/Get/GetTests.cs b/test/OperationsExampleTests/Get/GetTests.cs deleted file mode 100644 index f0d3fdffd8..0000000000 --- a/test/OperationsExampleTests/Get/GetTests.cs +++ /dev/null @@ -1,73 +0,0 @@ - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Models.Operations; -using JsonApiDotNetCoreExample.Data; -using OperationsExampleTests.Factories; -using Xunit; - -namespace OperationsExampleTests -{ - public class GetByIdTests : Fixture, IDisposable - { - private readonly Faker _faker = new Faker(); - - [Fact] - public async Task Can_Get_Authors() - { - // arrange - var expectedCount = _faker.Random.Int(1, 10); - var context = GetService(); - context.Articles.RemoveRange(context.Articles); - context.Authors.RemoveRange(context.Authors); - var authors = AuthorFactory.Get(expectedCount); - context.AddRange(authors); - context.SaveChanges(); - - var content = new - { - operations = new[] { - new Dictionary { - { "op", "get"}, - { "ref", new { type = "authors" } } - } - } - }; - - // act - var result = await PatchAsync("api/bulk", content); - - // assert - Assert.NotNull(result.response); - Assert.NotNull(result.data); - Assert.Equal(HttpStatusCode.OK, result.response.StatusCode); - Assert.Single(result.data.Operations); - Assert.Equal(expectedCount, result.data.Operations.Single().DataList.Count); - } - - [Fact] - public async Task Get_Non_Existent_Type_Returns_400() - { - // arrange - var content = new - { - operations = new[] { - new Dictionary { - { "op", "get"}, - { "ref", new { type = "non-existent-type" } } - } - } - }; - - // act - var result = await PatchAsync("api/bulk", content); - - // assert - Assert.Equal(HttpStatusCode.BadRequest, result.response.StatusCode); - } - } -} diff --git a/test/OperationsExampleTests/OperationsExampleTests.csproj b/test/OperationsExampleTests/OperationsExampleTests.csproj index f84b550354..f4ba1d8314 100644 --- a/test/OperationsExampleTests/OperationsExampleTests.csproj +++ b/test/OperationsExampleTests/OperationsExampleTests.csproj @@ -1,24 +1,22 @@ $(NetCoreAppVersion) - false - OperationsExampleTests + - - - - - - + + PreserveNewest + + - - + + + - - PreserveNewest - + + + diff --git a/test/OperationsExampleTests/Remove/RemoveTests.cs b/test/OperationsExampleTests/Remove/RemoveTests.cs index 70ffccbdbd..e58226a989 100644 --- a/test/OperationsExampleTests/Remove/RemoveTests.cs +++ b/test/OperationsExampleTests/Remove/RemoveTests.cs @@ -4,24 +4,30 @@ using System.Threading.Tasks; using Bogus; using JsonApiDotNetCore.Models.Operations; -using JsonApiDotNetCoreExample.Data; +using OperationsExample; using OperationsExampleTests.Factories; using Xunit; -namespace OperationsExampleTests +namespace OperationsExampleTests.Remove { - public class RemoveTests : Fixture + [Collection("WebHostCollection")] + public class RemoveTests { + private readonly TestFixture _fixture; private readonly Faker _faker = new Faker(); + public RemoveTests(TestFixture fixture) + { + _fixture = fixture; + } + [Fact] public async Task Can_Remove_Author() { // arrange - var context = GetService(); var author = AuthorFactory.Get(); - context.Authors.Add(author); - context.SaveChanges(); + _fixture.Context.AuthorDifferentDbContextName.Add(author); + _fixture.Context.SaveChanges(); var content = new { @@ -34,14 +40,14 @@ public async Task Can_Remove_Author() }; // act - var (response, data) = await PatchAsync("api/bulk", content); + var (response, data) = await _fixture.PatchAsync("api/bulk", content); // assert Assert.NotNull(response); Assert.NotNull(data); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); Assert.Empty(data.Operations); - Assert.Null(context.Authors.SingleOrDefault(a => a.Id == author.Id)); + Assert.Null(_fixture.Context.AuthorDifferentDbContextName.SingleOrDefault(a => a.Id == author.Id)); } [Fact] @@ -49,12 +55,10 @@ public async Task Can_Remove_Authors() { // arrange var count = _faker.Random.Int(1, 10); - var context = GetService(); - var authors = AuthorFactory.Get(count); - context.Authors.AddRange(authors); - context.SaveChanges(); + _fixture.Context.AuthorDifferentDbContextName.AddRange(authors); + _fixture.Context.SaveChanges(); var content = new { @@ -70,16 +74,16 @@ public async Task Can_Remove_Authors() ); // act - var (response, data) = await PatchAsync("api/bulk", content); + var (response, data) = await _fixture.PatchAsync("api/bulk", content); // assert Assert.NotNull(response); Assert.NotNull(data); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); Assert.Empty(data.Operations); for (int i = 0; i < count; i++) - Assert.Null(context.Authors.SingleOrDefault(a => a.Id == authors[i].Id)); + Assert.Null(_fixture.Context.AuthorDifferentDbContextName.SingleOrDefault(a => a.Id == authors[i].Id)); } } } diff --git a/test/OperationsExampleTests/TestFixture.cs b/test/OperationsExampleTests/TestFixture.cs new file mode 100644 index 0000000000..359f6621df --- /dev/null +++ b/test/OperationsExampleTests/TestFixture.cs @@ -0,0 +1,73 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCoreExample.Data; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; +using Xunit; + +namespace OperationsExampleTests +{ + public class TestFixture : IDisposable + where TStartup : class + { + private readonly TestServer _server; + private readonly HttpClient _client; + private bool _isDisposed; + + public AppDbContext Context { get; } + + public TestFixture() + { + var builder = WebHost.CreateDefaultBuilder().UseStartup(); + _server = new TestServer(builder); + + _client = _server.CreateClient(); + + var dbContextResolver = _server.Host.Services.GetRequiredService(); + Context = (AppDbContext) dbContextResolver.GetContext(); + } + + public void AssertEqualStatusCode(HttpStatusCode expected, HttpResponseMessage response) + { + var responseBody = response.Content.ReadAsStringAsync().Result; + Assert.True(expected == response.StatusCode, + $"Got {response.StatusCode} status code instead of {expected}. Response body: {responseBody}"); + } + + public void Dispose() + { + if (!_isDisposed) + { + _isDisposed = true; + + _client.Dispose(); + _server.Dispose(); + } + } + + public async Task<(HttpResponseMessage response, T data)> PatchAsync(string route, object data) + { + var response = await PatchAsync(route, data); + var json = await response.Content.ReadAsStringAsync(); + var obj = JsonConvert.DeserializeObject(json); + return (response, obj); + } + + private async Task PatchAsync(string route, object data) + { + var httpMethod = new HttpMethod("PATCH"); + var request = new HttpRequestMessage(httpMethod, route); + request.Content = new StringContent(JsonConvert.SerializeObject(data)); + request.Content.Headers.ContentLength = 1; + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + return await _client.SendAsync(request); + } + } +} diff --git a/test/OperationsExampleTests/TestStartup.cs b/test/OperationsExampleTests/TestStartup.cs deleted file mode 100644 index 449c193177..0000000000 --- a/test/OperationsExampleTests/TestStartup.cs +++ /dev/null @@ -1,25 +0,0 @@ -using JsonApiDotNetCore.Data; -using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample.Data; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.DependencyInjection; -using OperationsExample; -using System; -using UnitTests; - -namespace OperationsExampleTests -{ - public class TestStartup : Startup - { - public TestStartup(IHostingEnvironment env) : base(env) - { } - - public override IServiceProvider ConfigureServices(IServiceCollection services) - { - base.ConfigureServices(services); - services.AddScoped(); - services.AddSingleton>(); - return services.BuildServiceProvider(); - } - } -} diff --git a/test/OperationsExampleTests/Transactions/TransactionFailureTests.cs b/test/OperationsExampleTests/Transactions/TransactionFailureTests.cs index 191711651d..2d7e525752 100644 --- a/test/OperationsExampleTests/Transactions/TransactionFailureTests.cs +++ b/test/OperationsExampleTests/Transactions/TransactionFailureTests.cs @@ -3,29 +3,35 @@ using System.Net; using System.Threading.Tasks; using Bogus; -using JsonApiDotNetCore.Internal; -using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; +using OperationsExample; using OperationsExampleTests.Factories; using Xunit; -namespace OperationsExampleTests +namespace OperationsExampleTests.Transactions { - public class TransactionFailureTests : Fixture + [Collection("WebHostCollection")] + public class TransactionFailureTests { + private readonly TestFixture _fixture; private readonly Faker _faker = new Faker(); + public TransactionFailureTests(TestFixture fixture) + { + _fixture = fixture; + } + [Fact] public async Task Cannot_Create_Author_If_Article_Creation_Fails() { // arrange - var context = GetService(); var author = AuthorFactory.Get(); var article = ArticleFactory.Get(); // do this so that the name is random enough for db validations - author.Name = Guid.NewGuid().ToString("N"); - article.Name = Guid.NewGuid().ToString("N"); + author.FirstName = Guid.NewGuid().ToString("N"); + article.Caption = Guid.NewGuid().ToString("N"); var content = new { @@ -35,8 +41,8 @@ public async Task Cannot_Create_Author_If_Article_Creation_Fails() data = new { type = "authors", attributes = new { - name = author.Name - }, + firstName = author.FirstName + } } }, new { @@ -44,35 +50,34 @@ public async Task Cannot_Create_Author_If_Article_Creation_Fails() data = new { type = "articles", attributes = new { - name = article.Name + caption = article.Caption }, - // by not including the author, the article creation will fail - // relationships = new { - // author = new { - // data = new { - // type = "authors", - // lid = authorLocalId - // } - // } - // } + relationships = new { + author = new { + data = new { + type = "authors", + id = 99999999 + } + } + } } } } }; // act - var (response, data) = await PatchAsync("api/bulk", content); + var (response, data) = await _fixture.PatchAsync("api/bulk", content); // assert Assert.NotNull(response); // for now, it is up to application implementations to perform validation and // provide the proper HTTP response code - Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + _fixture.AssertEqualStatusCode(HttpStatusCode.InternalServerError, response); Assert.Single(data.Errors); - Assert.Contains("operation[1] (add)", data.Errors[0].Title); + Assert.Contains("operation[1] (add)", data.Errors[0].Detail); - var dbAuthors = await context.Authors.Where(a => a.Name == author.Name).ToListAsync(); - var dbArticles = await context.Articles.Where(a => a.Name == article.Name).ToListAsync(); + var dbAuthors = await _fixture.Context.AuthorDifferentDbContextName.Where(a => a.FirstName == author.FirstName).ToListAsync(); + var dbArticles = await _fixture.Context.Articles.Where(a => a.Caption == article.Caption).ToListAsync(); Assert.Empty(dbAuthors); Assert.Empty(dbArticles); } diff --git a/test/OperationsExampleTests/Update/UpdateTests.cs b/test/OperationsExampleTests/Update/UpdateTests.cs index c5d220b2f5..9ca19526ae 100644 --- a/test/OperationsExampleTests/Update/UpdateTests.cs +++ b/test/OperationsExampleTests/Update/UpdateTests.cs @@ -1,28 +1,34 @@ using Bogus; using JsonApiDotNetCore.Models.Operations; -using JsonApiDotNetCoreExample.Data; using OperationsExampleTests.Factories; using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading.Tasks; +using OperationsExample; using Xunit; namespace OperationsExampleTests.Update { - public class UpdateTests : Fixture + [Collection("WebHostCollection")] + public class UpdateTests { + private readonly TestFixture _fixture; private readonly Faker _faker = new Faker(); + public UpdateTests(TestFixture fixture) + { + _fixture = fixture; + } + [Fact] public async Task Can_Update_Author() { // arrange - var context = GetService(); var author = AuthorFactory.Get(); var updates = AuthorFactory.Get(); - context.Authors.Add(author); - context.SaveChanges(); + _fixture.Context.AuthorDifferentDbContextName.Add(author); + _fixture.Context.SaveChanges(); var content = new { @@ -38,7 +44,7 @@ public async Task Can_Update_Author() id = author.Id, attributes = new { - name = updates.Name + firstName = updates.FirstName } } }, } @@ -46,16 +52,16 @@ public async Task Can_Update_Author() }; // act - var (response, data) = await PatchAsync("api/bulk", content); + var (response, data) = await _fixture.PatchAsync("api/bulk", content); // assert Assert.NotNull(response); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); Assert.NotNull(data); Assert.Single(data.Operations); - var attrs = data.Operations.Single().DataObject.Attributes; - Assert.Equal(updates.Name, attrs["name"]); + Assert.Equal(OperationCode.update, data.Operations.Single().Op); + Assert.Null(data.Operations.Single().DataObject); } [Fact] @@ -63,13 +69,12 @@ public async Task Can_Update_Authors() { // arrange var count = _faker.Random.Int(1, 10); - var context = GetService(); var authors = AuthorFactory.Get(count); var updates = AuthorFactory.Get(count); - context.Authors.AddRange(authors); - context.SaveChanges(); + _fixture.Context.AuthorDifferentDbContextName.AddRange(authors); + _fixture.Context.SaveChanges(); var content = new { @@ -88,24 +93,24 @@ public async Task Can_Update_Authors() id = authors[i].Id, attributes = new { - name = updates[i].Name + firstName = updates[i].FirstName } } }, }); // act - var (response, data) = await PatchAsync("api/bulk", content); + var (response, data) = await _fixture.PatchAsync("api/bulk", content); // assert Assert.NotNull(response); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); + _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); Assert.NotNull(data); Assert.Equal(count, data.Operations.Count); for (int i = 0; i < count; i++) { - var attrs = data.Operations[i].DataObject.Attributes; - Assert.Equal(updates[i].Name, attrs["name"]); + Assert.Equal(OperationCode.update, data.Operations[i].Op); + Assert.Null(data.Operations[i].DataObject); } } } diff --git a/test/OperationsExampleTests/WebHostCollection.cs b/test/OperationsExampleTests/WebHostCollection.cs new file mode 100644 index 0000000000..b7230d54da --- /dev/null +++ b/test/OperationsExampleTests/WebHostCollection.cs @@ -0,0 +1,10 @@ +using OperationsExample; +using Xunit; + +namespace OperationsExampleTests +{ + [CollectionDefinition("WebHostCollection")] + public class WebHostCollection : ICollectionFixture> + { + } +} diff --git a/test/OperationsExampleTests/appsettings.json b/test/OperationsExampleTests/appsettings.json deleted file mode 100644 index 84f8cf4220..0000000000 --- a/test/OperationsExampleTests/appsettings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "Data": { - "DefaultConnection": - "Host=localhost;Port=5432;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=postgres" - }, - "Logging": { - "IncludeScopes": false, - "LogLevel": { - "Default": "Warning", - "System": "Warning", - "Microsoft": "Warning", - "JsonApiDotNetCore.Middleware.JsonApiExceptionFilter": "Critical" - } - } -} diff --git a/test/OperationsExampleTests/xunit.runner.json b/test/OperationsExampleTests/xunit.runner.json new file mode 100644 index 0000000000..9db029ba52 --- /dev/null +++ b/test/OperationsExampleTests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "parallelizeAssembly": false, + "parallelizeTestCollections": false +} From f21e31743f825f59d300a330d436dcb6147eff78 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 24 Nov 2020 12:02:00 +0100 Subject: [PATCH 003/123] Removed option to enable the atomic-operations feature. It is implicitly enabled when the appropriate controller is added to the project. --- src/Examples/OperationsExample/Startup.cs | 1 - .../Configuration/IJsonApiOptions.cs | 2 -- .../Configuration/JsonApiApplicationBuilder.cs | 11 +++-------- .../Configuration/JsonApiOptions.cs | 11 ----------- .../Services/Operations/OperationsProcessor.cs | 12 +++++++++--- 5 files changed, 12 insertions(+), 25 deletions(-) diff --git a/src/Examples/OperationsExample/Startup.cs b/src/Examples/OperationsExample/Startup.cs index 6657fced72..7681a6c35c 100644 --- a/src/Examples/OperationsExample/Startup.cs +++ b/src/Examples/OperationsExample/Startup.cs @@ -31,7 +31,6 @@ public virtual void ConfigureServices(IServiceCollection services) services.AddJsonApi(options => { options.IncludeExceptionStackTraceInErrors = true; - options.EnableOperations = true; options.SerializerSettings.Formatting = Formatting.Indented; }); } diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index b81cab602f..96717fe796 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -172,8 +172,6 @@ public interface IJsonApiOptions /// int? MaximumIncludeDepth { get; } - bool EnableOperations { get; set; } - /// /// 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 ee23b65956..c012cc7524 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -134,17 +134,13 @@ public void ConfigureServiceContainer(ICollection dbContextTypes) } } - if (_options.EnableOperations) - { - AddOperationServices(); - } - AddResourceLayer(); AddRepositoryLayer(); AddServiceLayer(); AddMiddlewareLayer(); AddSerializationLayer(); AddQueryStringLayer(); + AddAtomicOperationsLayer(); AddResourceHooks(); @@ -274,9 +270,10 @@ private void AddSerializationLayer() _services.AddScoped(); } - private void AddOperationServices() + private void AddAtomicOperationsLayer() { _services.AddScoped(); + _services.AddScoped(); _services.AddScoped(typeof(ICreateOpProcessor<>), typeof(CreateOpProcessor<>)); _services.AddScoped(typeof(ICreateOpProcessor<,>), typeof(CreateOpProcessor<,>)); @@ -286,8 +283,6 @@ private void AddOperationServices() _services.AddScoped(typeof(IUpdateOpProcessor<>), typeof(UpdateOpProcessor<>)); _services.AddScoped(typeof(IUpdateOpProcessor<,>), typeof(UpdateOpProcessor<,>)); - - _services.AddScoped(); } private void AddResourcesFromDbContext(DbContext dbContext, ResourceGraphBuilder builder) diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index c422be27c7..c999508574 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -67,17 +67,6 @@ public sealed class JsonApiOptions : IJsonApiOptions /// public int? MaximumIncludeDepth { get; set; } - /// - /// Whether or not to allow json:api v1.1 operation requests. - /// This is a beta feature and there may be breaking changes - /// in subsequent releases. For now, ijt should be considered - /// experimental. - /// - /// - /// This will be enabled by default in a subsequent patch JsonApiDotNetCore v2.2.x - /// - public bool EnableOperations { get; set; } - /// public JsonSerializerSettings SerializerSettings { get; } = new JsonSerializerSettings { diff --git a/src/JsonApiDotNetCore/Services/Operations/OperationsProcessor.cs b/src/JsonApiDotNetCore/Services/Operations/OperationsProcessor.cs index 3ca480a011..9bba9b4400 100644 --- a/src/JsonApiDotNetCore/Services/Operations/OperationsProcessor.cs +++ b/src/JsonApiDotNetCore/Services/Operations/OperationsProcessor.cs @@ -30,13 +30,19 @@ public class OperationsProcessor : IOperationsProcessor public OperationsProcessor( IOperationProcessorResolver processorResolver, - IDbContextResolver dbContextResolver, IJsonApiRequest request, ITargetedFields targetedFields, - IResourceGraph resourceGraph) + IResourceGraph resourceGraph, + IEnumerable dbContextResolvers) { + var resolvers = dbContextResolvers.ToArray(); + if (resolvers.Length != 1) + { + throw new InvalidOperationException("TODO: At least one DbContext is required for atomic operations. Multiple DbContexts are currently not supported."); + } + _processorResolver = processorResolver; - _dbContext = dbContextResolver.GetContext(); + _dbContext = resolvers[0].GetContext(); _request = request; _targetedFields = targetedFields; _resourceGraph = resourceGraph; From 6d8aa36f3ff800c25ab09f08cb458a98daa7ab5c Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 24 Nov 2020 17:54:20 +0100 Subject: [PATCH 004/123] Renames and namespace moves --- .../Controllers/AtomicOperationsController.cs | 15 +++ .../Controllers/OperationsController.cs | 15 --- .../AtomicOperationsProcessor.cs} | 125 +++++++++--------- .../IAtomicOperationsProcessor.cs | 15 +++ .../Processors/CreateOperationProcessor.cs | 67 ++++++++++ .../Processors/IAtomicOperationProcessor.cs | 14 ++ .../Processors/ICreateOperationProcessor.cs | 21 +++ .../Processors/IRemoveOperationProcessor.cs | 21 +++ .../Processors/IUpdateOperationProcessor.cs | 21 +++ .../Processors/RemoveOperationProcessor.cs | 53 ++++++++ .../Processors/UpdateOperationProcessor.cs | 76 +++++++++++ .../AtomicOperationProcessorResolver.cs | 66 +++++++++ .../IAtomicOperationProcessorResolver.cs | 26 ++++ .../JsonApiApplicationBuilder.cs | 20 +-- ...s => JsonApiAtomicOperationsController.cs} | 26 ++-- .../Models/Operations/Operation.cs | 82 ------------ .../Models/Operations/OperationCode.cs | 13 -- .../Models/Operations/OperationsDocument.cs | 17 --- .../Models/Operations/Params.cs | 13 -- .../Serialization/IJsonApiDeserializer.cs | 5 +- .../Serialization/JsonApiReader.cs | 2 +- .../Serialization/Objects/AtomicOperation.cs | 38 ++++++ .../Objects/AtomicOperationCode.cs | 16 +++ .../Objects/AtomicOperationsDocument.cs | 14 ++ .../Objects}/ResourceReference.cs | 3 +- .../Serialization/RequestDeserializer.cs | 5 +- .../Serialization/ResponseSerializer.cs | 7 +- .../Services/Operations/IOpProcessor.cs | 11 -- .../Operations/OperationProcessorResolver.cs | 101 -------------- .../Processors/CreateOpProcessor.cs | 73 ---------- .../Processors/RemoveOpProcessor.cs | 71 ---------- .../Processors/UpdateOpProcessor.cs | 83 ------------ test/OperationsExampleTests/Add/AddTests.cs | 24 ++-- .../Remove/RemoveTests.cs | 6 +- .../Transactions/TransactionFailureTests.cs | 4 +- .../Update/UpdateTests.cs | 14 +- 36 files changed, 584 insertions(+), 599 deletions(-) create mode 100644 src/Examples/OperationsExample/Controllers/AtomicOperationsController.cs delete mode 100644 src/Examples/OperationsExample/Controllers/OperationsController.cs rename src/JsonApiDotNetCore/{Services/Operations/OperationsProcessor.cs => AtomicOperations/AtomicOperationsProcessor.cs} (53%) create mode 100644 src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsProcessor.cs create mode 100644 src/JsonApiDotNetCore/AtomicOperations/Processors/CreateOperationProcessor.cs create mode 100644 src/JsonApiDotNetCore/AtomicOperations/Processors/IAtomicOperationProcessor.cs create mode 100644 src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateOperationProcessor.cs create mode 100644 src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveOperationProcessor.cs create mode 100644 src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateOperationProcessor.cs create mode 100644 src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveOperationProcessor.cs create mode 100644 src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateOperationProcessor.cs create mode 100644 src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs create mode 100644 src/JsonApiDotNetCore/Configuration/IAtomicOperationProcessorResolver.cs rename src/JsonApiDotNetCore/Controllers/{JsonApiOperationsController.cs => JsonApiAtomicOperationsController.cs} (60%) delete mode 100644 src/JsonApiDotNetCore/Models/Operations/Operation.cs delete mode 100644 src/JsonApiDotNetCore/Models/Operations/OperationCode.cs delete mode 100644 src/JsonApiDotNetCore/Models/Operations/OperationsDocument.cs delete mode 100644 src/JsonApiDotNetCore/Models/Operations/Params.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Objects/AtomicOperation.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationCode.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationsDocument.cs rename src/JsonApiDotNetCore/{Models/Operations => Serialization/Objects}/ResourceReference.cs (67%) delete mode 100644 src/JsonApiDotNetCore/Services/Operations/IOpProcessor.cs delete mode 100644 src/JsonApiDotNetCore/Services/Operations/OperationProcessorResolver.cs delete mode 100644 src/JsonApiDotNetCore/Services/Operations/Processors/CreateOpProcessor.cs delete mode 100644 src/JsonApiDotNetCore/Services/Operations/Processors/RemoveOpProcessor.cs delete mode 100644 src/JsonApiDotNetCore/Services/Operations/Processors/UpdateOpProcessor.cs diff --git a/src/Examples/OperationsExample/Controllers/AtomicOperationsController.cs b/src/Examples/OperationsExample/Controllers/AtomicOperationsController.cs new file mode 100644 index 0000000000..99ca513698 --- /dev/null +++ b/src/Examples/OperationsExample/Controllers/AtomicOperationsController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Controllers; +using Microsoft.AspNetCore.Mvc; + +namespace OperationsExample.Controllers +{ + [Route("api/v1/operations")] + public class AtomicOperationsController : JsonApiAtomicOperationsController + { + public AtomicOperationsController(IAtomicOperationsProcessor processor) + : base(processor) + { + } + } +} diff --git a/src/Examples/OperationsExample/Controllers/OperationsController.cs b/src/Examples/OperationsExample/Controllers/OperationsController.cs deleted file mode 100644 index 79a5e6ef48..0000000000 --- a/src/Examples/OperationsExample/Controllers/OperationsController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services.Operations; -using Microsoft.AspNetCore.Mvc; - -namespace OperationsExample.Controllers -{ - [Route("api/bulk")] - public class OperationsController : JsonApiOperationsController - { - public OperationsController(IOperationsProcessor processor) - : base(processor) - { - } - } -} diff --git a/src/JsonApiDotNetCore/Services/Operations/OperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs similarity index 53% rename from src/JsonApiDotNetCore/Services/Operations/OperationsProcessor.cs rename to src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs index 9bba9b4400..755361e5de 100644 --- a/src/JsonApiDotNetCore/Services/Operations/OperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs @@ -4,104 +4,105 @@ using System.Net; using System.Threading; using System.Threading.Tasks; +using JsonApiDotNetCore.AtomicOperations.Processors; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Models.Operations; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCore.Services.Operations +namespace JsonApiDotNetCore.AtomicOperations { - public interface IOperationsProcessor + /// + public class AtomicOperationsProcessor : IAtomicOperationsProcessor { - Task> ProcessAsync(List inputOps, CancellationToken cancellationToken); - } - - public class OperationsProcessor : IOperationsProcessor - { - private readonly IOperationProcessorResolver _processorResolver; + private readonly IAtomicOperationProcessorResolver _resolver; private readonly DbContext _dbContext; private readonly IJsonApiRequest _request; private readonly ITargetedFields _targetedFields; private readonly IResourceGraph _resourceGraph; - public OperationsProcessor( - IOperationProcessorResolver processorResolver, - IJsonApiRequest request, - ITargetedFields targetedFields, - IResourceGraph resourceGraph, + public AtomicOperationsProcessor(IAtomicOperationProcessorResolver resolver, IJsonApiRequest request, + ITargetedFields targetedFields, IResourceGraph resourceGraph, IEnumerable dbContextResolvers) { + _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); + _request = request ?? throw new ArgumentNullException(nameof(request)); + _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); + _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); + + if (dbContextResolvers == null) throw new ArgumentNullException(nameof(dbContextResolvers)); + var resolvers = dbContextResolvers.ToArray(); if (resolvers.Length != 1) { - throw new InvalidOperationException("TODO: At least one DbContext is required for atomic operations. Multiple DbContexts are currently not supported."); + throw new InvalidOperationException( + "TODO: At least one DbContext is required for atomic operations. Multiple DbContexts are currently not supported."); } - _processorResolver = processorResolver; _dbContext = resolvers[0].GetContext(); - _request = request; - _targetedFields = targetedFields; - _resourceGraph = resourceGraph; } - public async Task> ProcessAsync(List inputOps, CancellationToken cancellationToken) + public async Task> ProcessAsync(IList operations, CancellationToken cancellationToken) { - var outputOps = new List(); + if (operations == null) throw new ArgumentNullException(nameof(operations)); + + var outputOps = new List(); var opIndex = 0; - OperationCode? lastAttemptedOperation = null; // used for error messages only + AtomicOperationCode? lastAttemptedOperation = null; // used for error messages only using (var transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken)) { try { - foreach (var op in inputOps) + foreach (var operation in operations) { - lastAttemptedOperation = op.Op; - await ProcessOperation(op, outputOps, cancellationToken); + lastAttemptedOperation = operation.Code; + await ProcessOperation(operation, outputOps, cancellationToken); opIndex++; } await transaction.CommitAsync(cancellationToken); return outputOps; } - catch (JsonApiException e) + catch (JsonApiException exception) { await transaction.RollbackAsync(cancellationToken); - throw new JsonApiException(new Error(e.Error.StatusCode) + throw new JsonApiException(new Error(exception.Error.StatusCode) { Title = "Transaction failed on operation.", Detail = $"Transaction failed on operation[{opIndex}] ({lastAttemptedOperation})." - }, e); + }, exception); } - catch (Exception e) + catch (Exception exception) { await transaction.RollbackAsync(cancellationToken); throw new JsonApiException(new Error(HttpStatusCode.InternalServerError) { Title = "Transaction failed on operation.", Detail = $"Transaction failed on operation[{opIndex}] ({lastAttemptedOperation}) for an unexpected reason." - }, e); + }, exception); } } } - private async Task ProcessOperation(Operation op, List outputOps, CancellationToken cancellationToken) + private async Task ProcessOperation(AtomicOperation inputOperation, List outputOperations, CancellationToken cancellationToken) { - ReplaceLocalIdsInResourceObject(op.DataObject, outputOps); - ReplaceLocalIdsInRef(op.Ref, outputOps); + cancellationToken.ThrowIfCancellationRequested(); + + ReplaceLocalIdsInResourceObject(inputOperation.SingleData, outputOperations); + ReplaceLocalIdsInRef(inputOperation.Ref, outputOperations); string type = null; - if (op.Op == OperationCode.add || op.Op == OperationCode.update) + if (inputOperation.Code == AtomicOperationCode.Add || inputOperation.Code == AtomicOperationCode.Update) { - type = op.DataObject.Type; + type = inputOperation.SingleData.Type; } - else if (op.Op == OperationCode.remove) + else if (inputOperation.Code == AtomicOperationCode.Remove) { - type = op.Ref.Type; + type = inputOperation.Ref.Type; } ((JsonApiRequest)_request).PrimaryResource = _resourceGraph.GetResourceContext(type); @@ -109,27 +110,27 @@ private async Task ProcessOperation(Operation op, List outputOps, Can _targetedFields.Attributes.Clear(); _targetedFields.Relationships.Clear(); - var processor = GetOperationsProcessor(op); - var resultOp = await processor.ProcessAsync(op, cancellationToken); + var processor = GetOperationsProcessor(inputOperation); + var resultOp = await processor.ProcessAsync(inputOperation, cancellationToken); if (resultOp != null) - outputOps.Add(resultOp); + outputOperations.Add(resultOp); } - private void ReplaceLocalIdsInResourceObject(ResourceObject resourceObject, List outputOps) + private void ReplaceLocalIdsInResourceObject(ResourceObject resourceObject, List outputOperations) { if (resourceObject == null) return; // it is strange to me that a top level resource object might use a lid. // by not replacing it, we avoid a case where the first operation is an 'add' with an 'lid' - // and we would be unable to locate the matching 'lid' in 'outputOps' + // and we would be unable to locate the matching 'lid' in 'outputOperations' // // we also create a scenario where I might try to update a resource I just created - // in this case, the 'data.id' will be null, but the 'ref.id' will be replaced by the correct 'id' from 'outputOps' + // in this case, the 'data.id' will be null, but the 'ref.id' will be replaced by the correct 'id' from 'outputOperations' // // if(HasLocalId(resourceObject)) - // resourceObject.Id = GetIdFromLocalId(outputOps, resourceObject.LocalId); + // resourceObject.Id = GetIdFromLocalId(outputOperations, resourceObject.LocalId); if (resourceObject.Relationships != null) { @@ -139,30 +140,30 @@ private void ReplaceLocalIdsInResourceObject(ResourceObject resourceObject, List { foreach (var relationship in relationshipDictionary.Value.ManyData) if (HasLocalId(relationship)) - relationship.Id = GetIdFromLocalId(outputOps, relationship.LocalId); + relationship.Id = GetIdFromLocalId(outputOperations, relationship.LocalId); } else { var relationship = relationshipDictionary.Value.SingleData; if (HasLocalId(relationship)) - relationship.Id = GetIdFromLocalId(outputOps, relationship.LocalId); + relationship.Id = GetIdFromLocalId(outputOperations, relationship.LocalId); } } } } - private void ReplaceLocalIdsInRef(ResourceReference resourceRef, List outputOps) + private void ReplaceLocalIdsInRef(ResourceReference resourceReference, List outputOperations) { - if (resourceRef == null) return; - if (HasLocalId(resourceRef)) - resourceRef.Id = GetIdFromLocalId(outputOps, resourceRef.LocalId); + if (resourceReference == null) return; + if (HasLocalId(resourceReference)) + resourceReference.Id = GetIdFromLocalId(outputOperations, resourceReference.LocalId); } private bool HasLocalId(ResourceIdentifierObject rio) => string.IsNullOrEmpty(rio.LocalId) == false; - private string GetIdFromLocalId(List outputOps, string localId) + private string GetIdFromLocalId(List outputOps, string localId) { - var referencedOp = outputOps.FirstOrDefault(o => o.DataObject.LocalId == localId); + var referencedOp = outputOps.FirstOrDefault(o => o.SingleData.LocalId == localId); if (referencedOp == null) { throw new JsonApiException(new Error(HttpStatusCode.BadRequest) @@ -172,24 +173,24 @@ private string GetIdFromLocalId(List outputOps, string localId) }); } - return referencedOp.DataObject.Id; + return referencedOp.SingleData.Id; } - private IOpProcessor GetOperationsProcessor(Operation op) + private IAtomicOperationProcessor GetOperationsProcessor(AtomicOperation operation) { - switch (op.Op) + switch (operation.Code) { - case OperationCode.add: - return _processorResolver.LocateCreateService(op); - case OperationCode.remove: - return _processorResolver.LocateRemoveService(op); - case OperationCode.update: - return _processorResolver.LocateUpdateService(op); + case AtomicOperationCode.Add: + return _resolver.ResolveCreateProcessor(operation); + case AtomicOperationCode.Remove: + return _resolver.ResolveRemoveProcessor(operation); + case AtomicOperationCode.Update: + return _resolver.ResolveUpdateProcessor(operation); default: throw new JsonApiException(new Error(HttpStatusCode.BadRequest) { Title = "Invalid operation code.", - Detail = $"'{op.Op}' is not a valid operation code." + Detail = $"'{operation.Code}' is not a valid operation code." }); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsProcessor.cs new file mode 100644 index 0000000000..1685c1a569 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsProcessor.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.AtomicOperations +{ + /// + /// Processes a request that contains a list of atomic operations. + /// + public interface IAtomicOperationsProcessor + { + Task> ProcessAsync(IList operations, CancellationToken cancellationToken); + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateOperationProcessor.cs new file mode 100644 index 0000000000..0f3bb9f103 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateOperationProcessor.cs @@ -0,0 +1,67 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Building; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + public class CreateOperationProcessor : ICreateOperationProcessor + where TResource : class, IIdentifiable + { + private readonly ICreateService _service; + private readonly IJsonApiDeserializer _deserializer; + private readonly IResourceObjectBuilder _resourceObjectBuilder; + private readonly IResourceGraph _resourceGraph; + + public CreateOperationProcessor(ICreateService service, IJsonApiDeserializer deserializer, + IResourceObjectBuilder resourceObjectBuilder, IResourceGraph resourceGraph) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + _deserializer = deserializer ?? throw new ArgumentNullException(nameof(deserializer)); + _resourceObjectBuilder = resourceObjectBuilder ?? throw new ArgumentNullException(nameof(resourceObjectBuilder)); + _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); + } + + public async Task ProcessAsync(AtomicOperation operation, CancellationToken cancellationToken) + { + var model = (TResource) _deserializer.CreateResourceFromObject(operation.SingleData); + var result = await _service.CreateAsync(model, cancellationToken); + + var operationResult = new AtomicOperation + { + Code = AtomicOperationCode.Add + }; + + ResourceContext resourceContext = _resourceGraph.GetResourceContext(operation.SingleData.Type); + + operationResult.Data = + _resourceObjectBuilder.Build(result, resourceContext.Attributes, resourceContext.Relationships); + + // we need to persist the original request localId so that subsequent operations + // can locate the result of this operation by its localId + operationResult.SingleData.LocalId = operation.SingleData.LocalId; + + return operationResult; + } + } + + /// + /// Processes a single operation with code in a list of atomic operations. + /// + /// The resource type. + public class CreateOperationProcessor : CreateOperationProcessor, ICreateOperationProcessor + where TResource : class, IIdentifiable + { + public CreateOperationProcessor(ICreateService service, IJsonApiDeserializer deserializer, + IResourceObjectBuilder resourceObjectBuilder, IResourceGraph resourceGraph) + : base(service, deserializer, resourceObjectBuilder, resourceGraph) + { + } + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IAtomicOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAtomicOperationProcessor.cs new file mode 100644 index 0000000000..0a6a5ab400 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAtomicOperationProcessor.cs @@ -0,0 +1,14 @@ +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + /// Processes a single entry in a list of atomic operations. + /// + public interface IAtomicOperationProcessor + { + Task ProcessAsync(AtomicOperation operation, CancellationToken cancellationToken); + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateOperationProcessor.cs new file mode 100644 index 0000000000..6de983aba2 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateOperationProcessor.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + public interface ICreateOperationProcessor : ICreateOperationProcessor + where TResource : class, IIdentifiable + { + } + + /// + /// Processes a single operation with code in a list of atomic operations. + /// + /// The resource type. + /// The resource identifier type. + public interface ICreateOperationProcessor : IAtomicOperationProcessor + where TResource : class, IIdentifiable + { + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveOperationProcessor.cs new file mode 100644 index 0000000000..0fd0d0056d --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveOperationProcessor.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + public interface IRemoveOperationProcessor : IRemoveOperationProcessor + where TResource : class, IIdentifiable + { + } + + /// + /// Processes a single operation with code in a list of atomic operations. + /// + /// The resource type. + /// The resource identifier type. + public interface IRemoveOperationProcessor : IAtomicOperationProcessor + where TResource : class, IIdentifiable + { + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateOperationProcessor.cs new file mode 100644 index 0000000000..0ddc74b201 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateOperationProcessor.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + public interface IUpdateOperationProcessor : IUpdateOperationProcessor + where TResource : class, IIdentifiable + { + } + + /// + /// Processes a single operation with code in a list of atomic operations. + /// + /// The resource type. + /// The resource identifier type. + public interface IUpdateOperationProcessor : IAtomicOperationProcessor + where TResource : class, IIdentifiable + { + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveOperationProcessor.cs new file mode 100644 index 0000000000..f42495914d --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveOperationProcessor.cs @@ -0,0 +1,53 @@ +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + public class RemoveOperationProcessor : IRemoveOperationProcessor + where TResource : class, IIdentifiable + { + private readonly IDeleteService _service; + + public RemoveOperationProcessor(IDeleteService service) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + public async Task ProcessAsync(AtomicOperation operation, CancellationToken cancellationToken) + { + var stringId = operation.Ref?.Id; + if (string.IsNullOrWhiteSpace(stringId)) + { + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "The ref.id element is required for remove operations." + }); + } + + var id = (TId) TypeHelper.ConvertType(stringId, typeof(TId)); + await _service.DeleteAsync(id, cancellationToken); + + return null; + } + } + + /// + /// Processes a single operation with code in a list of atomic operations. + /// + /// The resource type. + public class RemoveOperationProcessor : RemoveOperationProcessor, IRemoveOperationProcessor + where TResource : class, IIdentifiable + { + public RemoveOperationProcessor(IDeleteService service) + : base(service) + { + } + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateOperationProcessor.cs new file mode 100644 index 0000000000..526700a5ee --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateOperationProcessor.cs @@ -0,0 +1,76 @@ +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Building; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + public class UpdateOperationProcessor : IUpdateOperationProcessor + where TResource : class, IIdentifiable + { + private readonly IUpdateService _service; + private readonly IJsonApiDeserializer _deserializer; + private readonly IResourceObjectBuilder _resourceObjectBuilder; + private readonly IResourceGraph _resourceGraph; + + public UpdateOperationProcessor(IUpdateService service, IJsonApiDeserializer deserializer, + IResourceObjectBuilder resourceObjectBuilder, IResourceGraph resourceGraph) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + _deserializer = deserializer ?? throw new ArgumentNullException(nameof(deserializer)); + _resourceObjectBuilder = resourceObjectBuilder ?? throw new ArgumentNullException(nameof(resourceObjectBuilder)); + _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); + } + + public async Task ProcessAsync(AtomicOperation operation, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(operation?.SingleData?.Id)) + { + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "The data.id element is required for replace operations." + }); + } + + var model = (TResource) _deserializer.CreateResourceFromObject(operation.SingleData); + + var result = await _service.UpdateAsync(model.Id, model, cancellationToken); + + ResourceObject data = null; + + if (result != null) + { + ResourceContext resourceContext = _resourceGraph.GetResourceContext(operation.SingleData.Type); + data = _resourceObjectBuilder.Build(result, resourceContext.Attributes, resourceContext.Relationships); + } + + return new AtomicOperation + { + Code = AtomicOperationCode.Update, + Data = data + }; + } + } + + /// + /// Processes a single operation with code in a list of atomic operations. + /// + /// The resource type. + public class UpdateOperationProcessor : UpdateOperationProcessor, IUpdateOperationProcessor + where TResource : class, IIdentifiable + { + public UpdateOperationProcessor(IUpdateService service, IJsonApiDeserializer deserializer, + IResourceObjectBuilder resourceObjectBuilder, IResourceGraph resourceGraph) + : base(service, deserializer, resourceObjectBuilder, resourceGraph) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs b/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs new file mode 100644 index 0000000000..ecdf4afc53 --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs @@ -0,0 +1,66 @@ +using System; +using System.Net; +using JsonApiDotNetCore.AtomicOperations.Processors; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Configuration +{ + /// + public class AtomicOperationProcessorResolver : IAtomicOperationProcessorResolver + { + private readonly IGenericServiceFactory _genericServiceFactory; + private readonly IResourceContextProvider _resourceContextProvider; + + /// + public AtomicOperationProcessorResolver(IGenericServiceFactory genericServiceFactory, + IResourceContextProvider resourceContextProvider) + { + _genericServiceFactory = genericServiceFactory ?? throw new ArgumentNullException(nameof(genericServiceFactory)); + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); + } + + /// + public IAtomicOperationProcessor ResolveCreateProcessor(AtomicOperation operation) + { + return Resolve(operation, typeof(ICreateOperationProcessor<,>)); + } + + /// + public IAtomicOperationProcessor ResolveRemoveProcessor(AtomicOperation operation) + { + return Resolve(operation, typeof(IRemoveOperationProcessor<,>)); + } + + /// + public IAtomicOperationProcessor ResolveUpdateProcessor(AtomicOperation operation) + { + return Resolve(operation, typeof(IUpdateOperationProcessor<,>)); + } + + private IAtomicOperationProcessor Resolve(AtomicOperation atomicOperation, Type processorInterface) + { + var resourceName = atomicOperation.GetResourceTypeName(); + var resourceContext = GetResourceContext(resourceName); + + return _genericServiceFactory.Get(processorInterface, + resourceContext.ResourceType, resourceContext.IdentityType + ); + } + + private ResourceContext GetResourceContext(string resourceName) + { + var resourceContext = _resourceContextProvider.GetResourceContext(resourceName); + if (resourceContext == null) + { + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "Unsupported resource type.", + Detail = $"This API does not expose a resource of type '{resourceName}'." + }); + } + + return resourceContext; + } + } +} diff --git a/src/JsonApiDotNetCore/Configuration/IAtomicOperationProcessorResolver.cs b/src/JsonApiDotNetCore/Configuration/IAtomicOperationProcessorResolver.cs new file mode 100644 index 0000000000..6cfb4643a8 --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/IAtomicOperationProcessorResolver.cs @@ -0,0 +1,26 @@ +using JsonApiDotNetCore.AtomicOperations.Processors; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Configuration +{ + /// + /// Used to resolve the registered at runtime, based on the resource type in the operation. + /// + public interface IAtomicOperationProcessorResolver + { + /// + /// Resolves a compatible . + /// + IAtomicOperationProcessor ResolveCreateProcessor(AtomicOperation operation); + + /// + /// Resolves a compatible . + /// + IAtomicOperationProcessor ResolveRemoveProcessor(AtomicOperation operation); + + /// + /// Resolves a compatible . + /// + IAtomicOperationProcessor ResolveUpdateProcessor(AtomicOperation operation); + } +} diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index c012cc7524..44b4ea899f 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; @@ -16,8 +18,6 @@ using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Building; using JsonApiDotNetCore.Services; -using JsonApiDotNetCore.Services.Operations; -using JsonApiDotNetCore.Services.Operations.Processors; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -272,17 +272,17 @@ private void AddSerializationLayer() private void AddAtomicOperationsLayer() { - _services.AddScoped(); - _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); - _services.AddScoped(typeof(ICreateOpProcessor<>), typeof(CreateOpProcessor<>)); - _services.AddScoped(typeof(ICreateOpProcessor<,>), typeof(CreateOpProcessor<,>)); + _services.AddScoped(typeof(ICreateOperationProcessor<>), typeof(CreateOperationProcessor<>)); + _services.AddScoped(typeof(ICreateOperationProcessor<,>), typeof(CreateOperationProcessor<,>)); - _services.AddScoped(typeof(IRemoveOpProcessor<>), typeof(RemoveOpProcessor<>)); - _services.AddScoped(typeof(IRemoveOpProcessor<,>), typeof(RemoveOpProcessor<,>)); + _services.AddScoped(typeof(IRemoveOperationProcessor<>), typeof(RemoveOperationProcessor<>)); + _services.AddScoped(typeof(IRemoveOperationProcessor<,>), typeof(RemoveOperationProcessor<,>)); - _services.AddScoped(typeof(IUpdateOpProcessor<>), typeof(UpdateOpProcessor<>)); - _services.AddScoped(typeof(IUpdateOpProcessor<,>), typeof(UpdateOpProcessor<,>)); + _services.AddScoped(typeof(IUpdateOperationProcessor<>), typeof(UpdateOperationProcessor<>)); + _services.AddScoped(typeof(IUpdateOperationProcessor<,>), typeof(UpdateOperationProcessor<,>)); } private void AddResourcesFromDbContext(DbContext dbContext, ResourceGraphBuilder builder) diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiAtomicOperationsController.cs similarity index 60% rename from src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs rename to src/JsonApiDotNetCore/Controllers/JsonApiAtomicOperationsController.cs index 9ec611e850..08d9f5ddb4 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiAtomicOperationsController.cs @@ -1,7 +1,8 @@ +using System; using System.Threading; using System.Threading.Tasks; -using JsonApiDotNetCore.Models.Operations; -using JsonApiDotNetCore.Services.Operations; +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Mvc; namespace JsonApiDotNetCore.Controllers @@ -9,16 +10,16 @@ namespace JsonApiDotNetCore.Controllers /// /// A controller to be used for bulk operations as defined in the json:api 1.1 specification /// - public class JsonApiOperationsController : ControllerBase + public class JsonApiAtomicOperationsController : ControllerBase { - private readonly IOperationsProcessor _operationsProcessor; + private readonly IAtomicOperationsProcessor _atomicOperationsProcessor; - /// + /// /// The processor to handle bulk operations. /// - public JsonApiOperationsController(IOperationsProcessor operationsProcessor) + public JsonApiAtomicOperationsController(IAtomicOperationsProcessor atomicOperationsProcessor) { - _operationsProcessor = operationsProcessor; + _atomicOperationsProcessor = atomicOperationsProcessor ?? throw new ArgumentNullException(nameof(atomicOperationsProcessor)); } /// @@ -30,7 +31,7 @@ public JsonApiOperationsController(IOperationsProcessor operationsProcessor) /// Propagates notification that request handling should be canceled. /// /// - /// PATCH /api/bulk HTTP/1.1 + /// PATCH /api/v1/operations HTTP/1.1 /// Content-Type: application/vnd.api+json /// /// { @@ -50,13 +51,16 @@ public JsonApiOperationsController(IOperationsProcessor operationsProcessor) /// /// [HttpPatch] - public virtual async Task PatchOperationsAsync([FromBody] OperationsDocument doc, CancellationToken cancellationToken) + public virtual async Task PatchOperationsAsync([FromBody] AtomicOperationsDocument doc, CancellationToken cancellationToken) { if (doc == null) return new StatusCodeResult(422); - var results = await _operationsProcessor.ProcessAsync(doc.Operations, cancellationToken); + var results = await _atomicOperationsProcessor.ProcessAsync(doc.Operations, cancellationToken); - return Ok(new OperationsDocument(results)); + return Ok(new AtomicOperationsDocument + { + Operations = results + }); } } } diff --git a/src/JsonApiDotNetCore/Models/Operations/Operation.cs b/src/JsonApiDotNetCore/Models/Operations/Operation.cs deleted file mode 100644 index 11a0139234..0000000000 --- a/src/JsonApiDotNetCore/Models/Operations/Operation.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Serialization.Objects; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; -using Newtonsoft.Json.Linq; - -namespace JsonApiDotNetCore.Models.Operations -{ - public class Operation - { - [JsonProperty("links", NullValueHandling = NullValueHandling.Ignore)] - public TopLevelLinks Links { get; set; } - - [JsonProperty("included", NullValueHandling = NullValueHandling.Ignore)] - public List Included { get; set; } - - [JsonProperty("meta", NullValueHandling = NullValueHandling.Ignore)] - public Dictionary Meta { get; set; } - - [JsonProperty("op"), JsonConverter(typeof(StringEnumConverter))] - public OperationCode Op { get; set; } - - [JsonProperty("ref", NullValueHandling = NullValueHandling.Ignore)] - public ResourceReference Ref { get; set; } - - [JsonProperty("params", NullValueHandling = NullValueHandling.Ignore)] - public Params Params { get; set; } - - [JsonProperty("data")] - public object Data - { - get - { - if (DataIsList) return DataList; - return DataObject; - } - set => SetData(value); - } - - private void SetData(object data) - { - if (data is JArray jArray) - { - DataIsList = true; - DataList = jArray.ToObject>(); - } - else if (data is List dataList) - { - DataIsList = true; - DataList = dataList; - } - else if (data is JObject jObject) - { - DataObject = jObject.ToObject(); - } - else if (data is ResourceObject dataObject) - { - DataObject = dataObject; - } - } - - [JsonIgnore] - public bool DataIsList { get; private set; } - - [JsonIgnore] - public List DataList { get; private set; } - - [JsonIgnore] - public ResourceObject DataObject { get; private set; } - - public string GetResourceTypeName() - { - if (Ref != null) - return Ref.Type; - - if (DataIsList) - return DataList[0].Type; - - return DataObject.Type; - } - } -} diff --git a/src/JsonApiDotNetCore/Models/Operations/OperationCode.cs b/src/JsonApiDotNetCore/Models/Operations/OperationCode.cs deleted file mode 100644 index 34586744ca..0000000000 --- a/src/JsonApiDotNetCore/Models/Operations/OperationCode.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; - -namespace JsonApiDotNetCore.Models.Operations -{ - [JsonConverter(typeof(StringEnumConverter))] - public enum OperationCode - { - add, - update, - remove - } -} diff --git a/src/JsonApiDotNetCore/Models/Operations/OperationsDocument.cs b/src/JsonApiDotNetCore/Models/Operations/OperationsDocument.cs deleted file mode 100644 index 3228e9ca88..0000000000 --- a/src/JsonApiDotNetCore/Models/Operations/OperationsDocument.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.Models.Operations -{ - public class OperationsDocument - { - public OperationsDocument() { } - public OperationsDocument(List operations) - { - Operations = operations; - } - - [JsonProperty("operations")] - public List Operations { get; set; } - } -} diff --git a/src/JsonApiDotNetCore/Models/Operations/Params.cs b/src/JsonApiDotNetCore/Models/Operations/Params.cs deleted file mode 100644 index 470e8f4aa3..0000000000 --- a/src/JsonApiDotNetCore/Models/Operations/Params.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Collections.Generic; - -namespace JsonApiDotNetCore.Models.Operations -{ - public class Params - { - public List Include { get; set; } - public List Sort { get; set; } - public Dictionary Filter { get; set; } - public string Page { get; set; } - public Dictionary Fields { get; set; } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs index db1094aa51..ceb7e743d3 100644 --- a/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs @@ -1,4 +1,3 @@ -using JsonApiDotNetCore.Models.Operations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; @@ -18,12 +17,12 @@ public interface IJsonApiDeserializer object Deserialize(string body); /// - /// Deserializes JSON into a and constructs entities + /// Deserializes JSON into a and constructs entities /// from . /// /// The JSON to be deserialized /// The operations document constructed from the content - object DeserializeOperationsRequestDocument(string body); + object DeserializeOperationsDocument(string body); /// /// Creates an instance of the referenced type in diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index 7af2399adf..763e4d26b1 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs @@ -49,7 +49,7 @@ public async Task ReadAsync(InputFormatterContext context) if (_request.IsBulkRequest) { - var operations = _deserializer.DeserializeOperationsRequestDocument(body); + var operations = _deserializer.DeserializeOperationsDocument(body); return await InputFormatterResult.SuccessAsync(operations); } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperation.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperation.cs new file mode 100644 index 0000000000..5113a3921b --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperation.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; + +namespace JsonApiDotNetCore.Serialization.Objects +{ + public class AtomicOperation : 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 ResourceReference Ref { get; set; } + + [JsonProperty("href", NullValueHandling = NullValueHandling.Ignore)] + public string Href { get; set; } + + public string GetResourceTypeName() + { + if (Ref != null) + { + return Ref.Type; + } + + if (IsManyData) + { + return ManyData.First().Type; + } + + return SingleData.Type; + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationCode.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationCode.cs new file mode 100644 index 0000000000..cf47e3774d --- /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/AtomicOperationsDocument.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationsDocument.cs new file mode 100644 index 0000000000..dc813f9b6e --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationsDocument.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Serialization.Objects +{ + /// + /// https://jsonapi.org/ext/atomic/#document-structure + /// + public class AtomicOperationsDocument + { + [JsonProperty("operations")] + public IList Operations { get; set; } + } +} diff --git a/src/JsonApiDotNetCore/Models/Operations/ResourceReference.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceReference.cs similarity index 67% rename from src/JsonApiDotNetCore/Models/Operations/ResourceReference.cs rename to src/JsonApiDotNetCore/Serialization/Objects/ResourceReference.cs index 7d281156df..95ec9c4e3f 100644 --- a/src/JsonApiDotNetCore/Models/Operations/ResourceReference.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceReference.cs @@ -1,7 +1,6 @@ -using JsonApiDotNetCore.Serialization.Objects; using Newtonsoft.Json; -namespace JsonApiDotNetCore.Models.Operations +namespace JsonApiDotNetCore.Serialization.Objects { public class ResourceReference : ResourceIdentifierObject { diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index 79b3a18037..debad50721 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -5,7 +5,6 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Models.Operations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; @@ -53,10 +52,10 @@ public object Deserialize(string body) return instance; } - public object DeserializeOperationsRequestDocument(string body) + public object DeserializeOperationsDocument(string body) { JToken bodyToken = LoadJToken(body); - var document = bodyToken.ToObject(); + var document = bodyToken.ToObject(); if (document?.Operations == null || !document.Operations.Any()) { diff --git a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs index 61f6d4867b..89c59a0105 100644 --- a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs @@ -3,7 +3,6 @@ using System.Linq; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Models.Operations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Building; @@ -67,7 +66,7 @@ public string Serialize(object data) return SerializeErrorDocument(errorDocument); } - if (data is OperationsDocument operationsDocument) + if (data is AtomicOperationsDocument operationsDocument) { return SerializeOperationsDocument(operationsDocument); } @@ -75,9 +74,9 @@ public string Serialize(object data) throw new InvalidOperationException("Data being returned must be errors or resources."); } - private string SerializeOperationsDocument(OperationsDocument operationsDocument) + private string SerializeOperationsDocument(AtomicOperationsDocument atomicOperationsDocument) { - return SerializeObject(operationsDocument, _options.SerializerSettings); + return SerializeObject(atomicOperationsDocument, _options.SerializerSettings); } private string SerializeErrorDocument(ErrorDocument errorDocument) diff --git a/src/JsonApiDotNetCore/Services/Operations/IOpProcessor.cs b/src/JsonApiDotNetCore/Services/Operations/IOpProcessor.cs deleted file mode 100644 index 2a0840898d..0000000000 --- a/src/JsonApiDotNetCore/Services/Operations/IOpProcessor.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using JsonApiDotNetCore.Models.Operations; - -namespace JsonApiDotNetCore.Services.Operations -{ - public interface IOpProcessor - { - Task ProcessAsync(Operation operation, CancellationToken cancellationToken); - } -} diff --git a/src/JsonApiDotNetCore/Services/Operations/OperationProcessorResolver.cs b/src/JsonApiDotNetCore/Services/Operations/OperationProcessorResolver.cs deleted file mode 100644 index 904f622129..0000000000 --- a/src/JsonApiDotNetCore/Services/Operations/OperationProcessorResolver.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System.Net; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Models.Operations; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCore.Services.Operations.Processors; - -namespace JsonApiDotNetCore.Services.Operations -{ - /// - /// Used to resolve at runtime based on the required operation - /// - public interface IOperationProcessorResolver - { - /// - /// Locates the correct - /// - IOpProcessor LocateCreateService(Operation operation); - - /// - /// Locates the correct - /// - IOpProcessor LocateRemoveService(Operation operation); - - /// - /// Locates the correct - /// - IOpProcessor LocateUpdateService(Operation operation); - } - - /// - public class OperationProcessorResolver : IOperationProcessorResolver - { - private readonly IGenericServiceFactory _genericServiceFactory; - private readonly IResourceContextProvider _resourceContextProvider; - - /// - public OperationProcessorResolver( - IGenericServiceFactory genericServiceFactory, - IResourceContextProvider resourceContextProvider) - { - _genericServiceFactory = genericServiceFactory; - _resourceContextProvider = resourceContextProvider; - } - - /// - public IOpProcessor LocateCreateService(Operation operation) - { - var resource = operation.GetResourceTypeName(); - - var contextEntity = GetResourceMetadata(resource); - - var processor = _genericServiceFactory.Get( - typeof(ICreateOpProcessor<,>), contextEntity.ResourceType, contextEntity.IdentityType - ); - - return processor; - } - - /// - public IOpProcessor LocateRemoveService(Operation operation) - { - var resource = operation.GetResourceTypeName(); - - var contextEntity = GetResourceMetadata(resource); - - var processor = _genericServiceFactory.Get( - typeof(IRemoveOpProcessor<,>), contextEntity.ResourceType, contextEntity.IdentityType - ); - - return processor; - } - - /// - public IOpProcessor LocateUpdateService(Operation operation) - { - var resource = operation.GetResourceTypeName(); - - var contextEntity = GetResourceMetadata(resource); - - var processor = _genericServiceFactory.Get( - typeof(IUpdateOpProcessor<,>), contextEntity.ResourceType, contextEntity.IdentityType - ); - - return processor; - } - - private ResourceContext GetResourceMetadata(string resourceName) - { - var contextEntity = _resourceContextProvider.GetResourceContext(resourceName); - if (contextEntity == null) - throw new JsonApiException(new Error(HttpStatusCode.BadRequest) - { - Title = "Unsupported resource type.", - Detail = $"This API does not expose a resource of type '{resourceName}'." - }); - - return contextEntity; - } - } -} diff --git a/src/JsonApiDotNetCore/Services/Operations/Processors/CreateOpProcessor.cs b/src/JsonApiDotNetCore/Services/Operations/Processors/CreateOpProcessor.cs deleted file mode 100644 index c6cbea6db9..0000000000 --- a/src/JsonApiDotNetCore/Services/Operations/Processors/CreateOpProcessor.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Models.Operations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Building; - -namespace JsonApiDotNetCore.Services.Operations.Processors -{ - public interface ICreateOpProcessor : ICreateOpProcessor - where T : class, IIdentifiable - { } - - public interface ICreateOpProcessor : IOpProcessor - where T : class, IIdentifiable - { } - - public class CreateOpProcessor - : CreateOpProcessor, ICreateOpProcessor - where T : class, IIdentifiable - { - public CreateOpProcessor( - ICreateService service, - IJsonApiDeserializer deserializer, - IResourceObjectBuilder resourceObjectBuilder, - IResourceGraph resourceGraph - ) : base(service, deserializer, resourceObjectBuilder, resourceGraph) - { } - } - - public class CreateOpProcessor : ICreateOpProcessor - where T : class, IIdentifiable - { - private readonly ICreateService _service; - private readonly IJsonApiDeserializer _deserializer; - private readonly IResourceObjectBuilder _resourceObjectBuilder; - private readonly IResourceGraph _resourceGraph; - - public CreateOpProcessor( - ICreateService service, - IJsonApiDeserializer deserializer, - IResourceObjectBuilder resourceObjectBuilder, - IResourceGraph resourceGraph) - { - _service = service; - _deserializer = deserializer; - _resourceObjectBuilder = resourceObjectBuilder; - _resourceGraph = resourceGraph; - } - - public async Task ProcessAsync(Operation operation, CancellationToken cancellationToken) - { - var model = (T)_deserializer.CreateResourceFromObject(operation.DataObject); - var result = await _service.CreateAsync(model, cancellationToken); - - var operationResult = new Operation - { - Op = OperationCode.add - }; - - ResourceContext resourceContext = _resourceGraph.GetResourceContext(operation.DataObject.Type); - - operationResult.Data = _resourceObjectBuilder.Build(result, resourceContext.Attributes, resourceContext.Relationships); - - // we need to persist the original request localId so that subsequent operations - // can locate the result of this operation by its localId - operationResult.DataObject.LocalId = operation.DataObject.LocalId; - - return operationResult; - } - } -} diff --git a/src/JsonApiDotNetCore/Services/Operations/Processors/RemoveOpProcessor.cs b/src/JsonApiDotNetCore/Services/Operations/Processors/RemoveOpProcessor.cs deleted file mode 100644 index 41f14fd074..0000000000 --- a/src/JsonApiDotNetCore/Services/Operations/Processors/RemoveOpProcessor.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Models.Operations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Building; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Services.Operations.Processors -{ - public interface IRemoveOpProcessor : IRemoveOpProcessor - where T : class, IIdentifiable - { } - - public interface IRemoveOpProcessor : IOpProcessor - where T : class, IIdentifiable - { } - - public class RemoveOpProcessor : RemoveOpProcessor, IRemoveOpProcessor - where T : class, IIdentifiable - { - public RemoveOpProcessor( - IDeleteService service, - IJsonApiDeserializer deserializer, - IResourceObjectBuilder resourceObjectBuilder, - IResourceGraph resourceGraph - ) : base(service, deserializer, resourceObjectBuilder, resourceGraph) - { } - } - - public class RemoveOpProcessor : IRemoveOpProcessor - where T : class, IIdentifiable - { - private readonly IDeleteService _service; - private readonly IJsonApiDeserializer _deserializer; - private readonly IResourceObjectBuilder _resourceObjectBuilder; - private readonly IResourceGraph _resourceGraph; - - public RemoveOpProcessor( - IDeleteService service, - IJsonApiDeserializer deserializer, - IResourceObjectBuilder resourceObjectBuilder, - IResourceGraph resourceGraph) - { - _service = service; - _deserializer = deserializer; - _resourceObjectBuilder = resourceObjectBuilder; - _resourceGraph = resourceGraph; - } - - public async Task ProcessAsync(Operation operation, CancellationToken cancellationToken) - { - var stringId = operation.Ref?.Id?.ToString(); - if (string.IsNullOrWhiteSpace(stringId)) - { - throw new JsonApiException(new Error(HttpStatusCode.BadRequest) - { - Title = "The ref.id element is required for remove operations." - }); - } - - var id = (TId)TypeHelper.ConvertType(stringId, typeof(TId)); - await _service.DeleteAsync(id, cancellationToken); - - return null; - } - } -} diff --git a/src/JsonApiDotNetCore/Services/Operations/Processors/UpdateOpProcessor.cs b/src/JsonApiDotNetCore/Services/Operations/Processors/UpdateOpProcessor.cs deleted file mode 100644 index a463377fc1..0000000000 --- a/src/JsonApiDotNetCore/Services/Operations/Processors/UpdateOpProcessor.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Models.Operations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Building; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Services.Operations.Processors -{ - public interface IUpdateOpProcessor : IUpdateOpProcessor - where T : class, IIdentifiable - { } - - public interface IUpdateOpProcessor : IOpProcessor - where T : class, IIdentifiable - { } - - public class UpdateOpProcessor : UpdateOpProcessor, IUpdateOpProcessor - where T : class, IIdentifiable - { - public UpdateOpProcessor( - IUpdateService service, - IJsonApiDeserializer deserializer, - IResourceObjectBuilder resourceObjectBuilder, - IResourceGraph resourceGraph - ) : base(service, deserializer, resourceObjectBuilder, resourceGraph) - { } - } - - public class UpdateOpProcessor : IUpdateOpProcessor - where T : class, IIdentifiable - { - private readonly IUpdateService _service; - private readonly IJsonApiDeserializer _deserializer; - private readonly IResourceObjectBuilder _resourceObjectBuilder; - private readonly IResourceGraph _resourceGraph; - - public UpdateOpProcessor( - IUpdateService service, - IJsonApiDeserializer deserializer, - IResourceObjectBuilder resourceObjectBuilder, - IResourceGraph resourceGraph) - { - _service = service; - _deserializer = deserializer; - _resourceObjectBuilder = resourceObjectBuilder; - _resourceGraph = resourceGraph; - } - - public async Task ProcessAsync(Operation operation, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(operation?.DataObject?.Id)) - { - throw new JsonApiException(new Error(HttpStatusCode.BadRequest) - { - Title = "The data.id element is required for replace operations." - }); - } - - var model = (T)_deserializer.CreateResourceFromObject(operation.DataObject); - - var result = await _service.UpdateAsync(model.Id, model, cancellationToken); - - ResourceObject data = null; - - if (result != null) - { - ResourceContext resourceContext = _resourceGraph.GetResourceContext(operation.DataObject.Type); - data = _resourceObjectBuilder.Build(result, resourceContext.Attributes, resourceContext.Relationships); - } - - return new Operation - { - Op = OperationCode.update, - Data = data - }; - } - } -} diff --git a/test/OperationsExampleTests/Add/AddTests.cs b/test/OperationsExampleTests/Add/AddTests.cs index ca948a3b6b..ddc31f39b2 100644 --- a/test/OperationsExampleTests/Add/AddTests.cs +++ b/test/OperationsExampleTests/Add/AddTests.cs @@ -3,7 +3,7 @@ using System.Net; using System.Threading.Tasks; using Bogus; -using JsonApiDotNetCore.Models.Operations; +using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using OperationsExample; using OperationsExampleTests.Factories; @@ -43,13 +43,13 @@ public async Task Can_Create_Author() }; // act - var (response, data) = await _fixture.PatchAsync("api/bulk", content); + var (response, data) = await _fixture.PatchAsync("api/v1/operations", content); // assert Assert.NotNull(response); _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - var id = int.Parse(data.Operations.Single().DataObject.Id); + var id = int.Parse(data.Operations.Single().SingleData.Id); var lastAuthor = await _fixture.Context.AuthorDifferentDbContextName.SingleAsync(a => a.Id == id); Assert.Equal(author.FirstName, lastAuthor.FirstName); } @@ -84,7 +84,7 @@ public async Task Can_Create_Authors() } // act - var (response, data) = await _fixture.PatchAsync("api/bulk", content); + var (response, data) = await _fixture.PatchAsync("api/v1/operations", content); // assert Assert.NotNull(response); @@ -93,7 +93,7 @@ public async Task Can_Create_Authors() for (int i = 0; i < expectedCount; i++) { - var dataObject = data.Operations[i].DataObject; + var dataObject = data.Operations[i].SingleData; var author = _fixture.Context.AuthorDifferentDbContextName.Single(a => a.Id == int.Parse(dataObject.Id)); Assert.Equal(authors[i].FirstName, author.FirstName); } @@ -137,7 +137,7 @@ public async Task Can_Create_Article_With_Existing_Author() }; // act - var (response, data) = await _fixture.PatchAsync("api/bulk", content); + var (response, data) = await _fixture.PatchAsync("api/v1/operations", content); // assert Assert.NotNull(response); @@ -157,7 +157,7 @@ public async Task Can_Create_Article_With_Existing_Author() //// article validation Assert.Single(lastAuthor.Articles); Assert.Equal(article.Caption, lastAuthor.Articles[0].Caption); - Assert.Equal(articleOperationResult.DataObject.Id, lastAuthor.Articles[0].StringId); + Assert.Equal(articleOperationResult.SingleData.Id, lastAuthor.Articles[0].StringId); } [Fact] @@ -205,7 +205,7 @@ public async Task Can_Create_Articles_With_Existing_Author() } // act - var (response, data) = await _fixture.PatchAsync("api/bulk", content); + var (response, data) = await _fixture.PatchAsync("api/v1/operations", content); // assert Assert.NotNull(response); @@ -268,7 +268,7 @@ public async Task Can_Create_Author_With_Article_Using_LocalId() }; // act - var (response, data) = await _fixture.PatchAsync("api/bulk", content); + var (response, data) = await _fixture.PatchAsync("api/v1/operations", content); // assert Assert.NotNull(response); @@ -276,20 +276,20 @@ public async Task Can_Create_Author_With_Article_Using_LocalId() Assert.Equal(2, data.Operations.Count); var authorOperationResult = data.Operations[0]; - var id = int.Parse(authorOperationResult.DataObject.Id); + var id = int.Parse(authorOperationResult.SingleData.Id); var lastAuthor = await _fixture.Context.AuthorDifferentDbContextName .Include(a => a.Articles) .SingleAsync(a => a.Id == id); var articleOperationResult = data.Operations[1]; // author validation - Assert.Equal(authorLocalId, authorOperationResult.DataObject.LocalId); + Assert.Equal(authorLocalId, authorOperationResult.SingleData.LocalId); Assert.Equal(author.FirstName, lastAuthor.FirstName); // article validation Assert.Single(lastAuthor.Articles); Assert.Equal(article.Caption, lastAuthor.Articles[0].Caption); - Assert.Equal(articleOperationResult.DataObject.Id, lastAuthor.Articles[0].StringId); + Assert.Equal(articleOperationResult.SingleData.Id, lastAuthor.Articles[0].StringId); } } } diff --git a/test/OperationsExampleTests/Remove/RemoveTests.cs b/test/OperationsExampleTests/Remove/RemoveTests.cs index e58226a989..8859cb63fe 100644 --- a/test/OperationsExampleTests/Remove/RemoveTests.cs +++ b/test/OperationsExampleTests/Remove/RemoveTests.cs @@ -3,7 +3,7 @@ using System.Net; using System.Threading.Tasks; using Bogus; -using JsonApiDotNetCore.Models.Operations; +using JsonApiDotNetCore.Serialization.Objects; using OperationsExample; using OperationsExampleTests.Factories; using Xunit; @@ -40,7 +40,7 @@ public async Task Can_Remove_Author() }; // act - var (response, data) = await _fixture.PatchAsync("api/bulk", content); + var (response, data) = await _fixture.PatchAsync("api/v1/operations", content); // assert Assert.NotNull(response); @@ -74,7 +74,7 @@ public async Task Can_Remove_Authors() ); // act - var (response, data) = await _fixture.PatchAsync("api/bulk", content); + var (response, data) = await _fixture.PatchAsync("api/v1/operations", content); // assert Assert.NotNull(response); diff --git a/test/OperationsExampleTests/Transactions/TransactionFailureTests.cs b/test/OperationsExampleTests/Transactions/TransactionFailureTests.cs index 2d7e525752..680381c0f3 100644 --- a/test/OperationsExampleTests/Transactions/TransactionFailureTests.cs +++ b/test/OperationsExampleTests/Transactions/TransactionFailureTests.cs @@ -66,7 +66,7 @@ public async Task Cannot_Create_Author_If_Article_Creation_Fails() }; // act - var (response, data) = await _fixture.PatchAsync("api/bulk", content); + var (response, data) = await _fixture.PatchAsync("api/v1/operations", content); // assert Assert.NotNull(response); @@ -74,7 +74,7 @@ public async Task Cannot_Create_Author_If_Article_Creation_Fails() // provide the proper HTTP response code _fixture.AssertEqualStatusCode(HttpStatusCode.InternalServerError, response); Assert.Single(data.Errors); - Assert.Contains("operation[1] (add)", data.Errors[0].Detail); + Assert.Contains("operation[1] (Add)", data.Errors[0].Detail); var dbAuthors = await _fixture.Context.AuthorDifferentDbContextName.Where(a => a.FirstName == author.FirstName).ToListAsync(); var dbArticles = await _fixture.Context.Articles.Where(a => a.Caption == article.Caption).ToListAsync(); diff --git a/test/OperationsExampleTests/Update/UpdateTests.cs b/test/OperationsExampleTests/Update/UpdateTests.cs index 9ca19526ae..b359c6dde2 100644 --- a/test/OperationsExampleTests/Update/UpdateTests.cs +++ b/test/OperationsExampleTests/Update/UpdateTests.cs @@ -1,10 +1,10 @@ using Bogus; -using JsonApiDotNetCore.Models.Operations; using OperationsExampleTests.Factories; using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading.Tasks; +using JsonApiDotNetCore.Serialization.Objects; using OperationsExample; using Xunit; @@ -52,7 +52,7 @@ public async Task Can_Update_Author() }; // act - var (response, data) = await _fixture.PatchAsync("api/bulk", content); + var (response, data) = await _fixture.PatchAsync("api/v1/operations", content); // assert Assert.NotNull(response); @@ -60,8 +60,8 @@ public async Task Can_Update_Author() Assert.NotNull(data); Assert.Single(data.Operations); - Assert.Equal(OperationCode.update, data.Operations.Single().Op); - Assert.Null(data.Operations.Single().DataObject); + Assert.Equal(AtomicOperationCode.Update, data.Operations.Single().Code); + Assert.Null(data.Operations.Single().SingleData); } [Fact] @@ -99,7 +99,7 @@ public async Task Can_Update_Authors() }); // act - var (response, data) = await _fixture.PatchAsync("api/bulk", content); + var (response, data) = await _fixture.PatchAsync("api/v1/operations", content); // assert Assert.NotNull(response); @@ -109,8 +109,8 @@ public async Task Can_Update_Authors() for (int i = 0; i < count; i++) { - Assert.Equal(OperationCode.update, data.Operations[i].Op); - Assert.Null(data.Operations[i].DataObject); + Assert.Equal(AtomicOperationCode.Update, data.Operations[i].Code); + Assert.Null(data.Operations[i].SingleData); } } } From 88f5eee03b481433ceecbdf8e0cd855c96963b75 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 25 Nov 2020 10:47:16 +0100 Subject: [PATCH 005/123] Change patch to post --- .../Controllers/JsonApiAtomicOperationsController.cs | 6 +++--- src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs | 2 +- test/OperationsExampleTests/Add/AddTests.cs | 10 +++++----- test/OperationsExampleTests/Remove/RemoveTests.cs | 4 ++-- test/OperationsExampleTests/TestFixture.cs | 8 ++++---- .../Transactions/TransactionFailureTests.cs | 2 +- test/OperationsExampleTests/Update/UpdateTests.cs | 4 ++-- 7 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiAtomicOperationsController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiAtomicOperationsController.cs index 08d9f5ddb4..a8676a5316 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiAtomicOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiAtomicOperationsController.cs @@ -31,7 +31,7 @@ public JsonApiAtomicOperationsController(IAtomicOperationsProcessor atomicOperat /// Propagates notification that request handling should be canceled. /// /// - /// PATCH /api/v1/operations HTTP/1.1 + /// POST /api/v1/operations HTTP/1.1 /// Content-Type: application/vnd.api+json /// /// { @@ -50,8 +50,8 @@ public JsonApiAtomicOperationsController(IAtomicOperationsProcessor atomicOperat /// } /// /// - [HttpPatch] - public virtual async Task PatchOperationsAsync([FromBody] AtomicOperationsDocument doc, CancellationToken cancellationToken) + [HttpPost] + public virtual async Task PostOperationsAsync([FromBody] AtomicOperationsDocument doc, CancellationToken cancellationToken) { if (doc == null) return new StatusCodeResult(422); diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index a339a9af77..00f1d09c00 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -72,7 +72,7 @@ public async Task Invoke(HttpContext httpContext, private static bool PathIsBulk(RouteValueDictionary routeValues) { var actionName = (string)routeValues["action"]; - return actionName == "PatchOperations"; + return actionName == "PostOperations"; } private static ResourceContext CreatePrimaryResourceContext(RouteValueDictionary routeValues, diff --git a/test/OperationsExampleTests/Add/AddTests.cs b/test/OperationsExampleTests/Add/AddTests.cs index ddc31f39b2..ad91d2d937 100644 --- a/test/OperationsExampleTests/Add/AddTests.cs +++ b/test/OperationsExampleTests/Add/AddTests.cs @@ -43,7 +43,7 @@ public async Task Can_Create_Author() }; // act - var (response, data) = await _fixture.PatchAsync("api/v1/operations", content); + var (response, data) = await _fixture.PostAsync("api/v1/operations", content); // assert Assert.NotNull(response); @@ -84,7 +84,7 @@ public async Task Can_Create_Authors() } // act - var (response, data) = await _fixture.PatchAsync("api/v1/operations", content); + var (response, data) = await _fixture.PostAsync("api/v1/operations", content); // assert Assert.NotNull(response); @@ -137,7 +137,7 @@ public async Task Can_Create_Article_With_Existing_Author() }; // act - var (response, data) = await _fixture.PatchAsync("api/v1/operations", content); + var (response, data) = await _fixture.PostAsync("api/v1/operations", content); // assert Assert.NotNull(response); @@ -205,7 +205,7 @@ public async Task Can_Create_Articles_With_Existing_Author() } // act - var (response, data) = await _fixture.PatchAsync("api/v1/operations", content); + var (response, data) = await _fixture.PostAsync("api/v1/operations", content); // assert Assert.NotNull(response); @@ -268,7 +268,7 @@ public async Task Can_Create_Author_With_Article_Using_LocalId() }; // act - var (response, data) = await _fixture.PatchAsync("api/v1/operations", content); + var (response, data) = await _fixture.PostAsync("api/v1/operations", content); // assert Assert.NotNull(response); diff --git a/test/OperationsExampleTests/Remove/RemoveTests.cs b/test/OperationsExampleTests/Remove/RemoveTests.cs index 8859cb63fe..78671106e1 100644 --- a/test/OperationsExampleTests/Remove/RemoveTests.cs +++ b/test/OperationsExampleTests/Remove/RemoveTests.cs @@ -40,7 +40,7 @@ public async Task Can_Remove_Author() }; // act - var (response, data) = await _fixture.PatchAsync("api/v1/operations", content); + var (response, data) = await _fixture.PostAsync("api/v1/operations", content); // assert Assert.NotNull(response); @@ -74,7 +74,7 @@ public async Task Can_Remove_Authors() ); // act - var (response, data) = await _fixture.PatchAsync("api/v1/operations", content); + var (response, data) = await _fixture.PostAsync("api/v1/operations", content); // assert Assert.NotNull(response); diff --git a/test/OperationsExampleTests/TestFixture.cs b/test/OperationsExampleTests/TestFixture.cs index 359f6621df..17b345d9de 100644 --- a/test/OperationsExampleTests/TestFixture.cs +++ b/test/OperationsExampleTests/TestFixture.cs @@ -52,17 +52,17 @@ public void Dispose() } } - public async Task<(HttpResponseMessage response, T data)> PatchAsync(string route, object data) + public async Task<(HttpResponseMessage response, T data)> PostAsync(string route, object data) { - var response = await PatchAsync(route, data); + var response = await PostAsync(route, data); var json = await response.Content.ReadAsStringAsync(); var obj = JsonConvert.DeserializeObject(json); return (response, obj); } - private async Task PatchAsync(string route, object data) + private async Task PostAsync(string route, object data) { - var httpMethod = new HttpMethod("PATCH"); + var httpMethod = HttpMethod.Post; var request = new HttpRequestMessage(httpMethod, route); request.Content = new StringContent(JsonConvert.SerializeObject(data)); request.Content.Headers.ContentLength = 1; diff --git a/test/OperationsExampleTests/Transactions/TransactionFailureTests.cs b/test/OperationsExampleTests/Transactions/TransactionFailureTests.cs index 680381c0f3..4518ab5cd3 100644 --- a/test/OperationsExampleTests/Transactions/TransactionFailureTests.cs +++ b/test/OperationsExampleTests/Transactions/TransactionFailureTests.cs @@ -66,7 +66,7 @@ public async Task Cannot_Create_Author_If_Article_Creation_Fails() }; // act - var (response, data) = await _fixture.PatchAsync("api/v1/operations", content); + var (response, data) = await _fixture.PostAsync("api/v1/operations", content); // assert Assert.NotNull(response); diff --git a/test/OperationsExampleTests/Update/UpdateTests.cs b/test/OperationsExampleTests/Update/UpdateTests.cs index b359c6dde2..2f2d45d0e8 100644 --- a/test/OperationsExampleTests/Update/UpdateTests.cs +++ b/test/OperationsExampleTests/Update/UpdateTests.cs @@ -52,7 +52,7 @@ public async Task Can_Update_Author() }; // act - var (response, data) = await _fixture.PatchAsync("api/v1/operations", content); + var (response, data) = await _fixture.PostAsync("api/v1/operations", content); // assert Assert.NotNull(response); @@ -99,7 +99,7 @@ public async Task Can_Update_Authors() }); // act - var (response, data) = await _fixture.PatchAsync("api/v1/operations", content); + var (response, data) = await _fixture.PostAsync("api/v1/operations", content); // assert Assert.NotNull(response); From 0d616c63fc079f426356ea191337222153886427 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 25 Nov 2020 11:55:42 +0100 Subject: [PATCH 006/123] Separate controller layers, align naming in deserializer, move request.IsBulkRequest to EndpointKind.AtomicOperations --- .../JsonApiDeserializerBenchmarks.cs | 2 +- .../Controllers/AtomicOperationsController.cs | 10 ++- .../BaseJsonApiAtomicOperationsController.cs | 79 +++++++++++++++++++ .../JsonApiAtomicOperationsController.cs | 66 +++++----------- .../Middleware/EndpointKind.cs | 7 +- .../Middleware/IJsonApiRequest.cs | 2 - .../Middleware/JsonApiMiddleware.cs | 25 ++++-- .../Middleware/JsonApiRequest.cs | 3 - .../Serialization/IJsonApiDeserializer.cs | 2 +- .../Serialization/JsonApiReader.cs | 10 +-- .../Serialization/RequestDeserializer.cs | 2 +- .../Models/ResourceConstructionTests.cs | 8 +- .../Server/RequestDeserializerTests.cs | 6 +- 13 files changed, 143 insertions(+), 79 deletions(-) create mode 100644 src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs diff --git a/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs b/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs index 3ca960ef87..a77496f6c4 100644 --- a/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs +++ b/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs @@ -43,6 +43,6 @@ public JsonApiDeserializerBenchmarks() } [Benchmark] - public object DeserializeSimpleObject() => _jsonApiDeserializer.Deserialize(Content); + public object DeserializeSimpleObject() => _jsonApiDeserializer.DeserializeDocument(Content); } } diff --git a/src/Examples/OperationsExample/Controllers/AtomicOperationsController.cs b/src/Examples/OperationsExample/Controllers/AtomicOperationsController.cs index 99ca513698..0099f76db6 100644 --- a/src/Examples/OperationsExample/Controllers/AtomicOperationsController.cs +++ b/src/Examples/OperationsExample/Controllers/AtomicOperationsController.cs @@ -1,14 +1,18 @@ using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Controllers.Annotations; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; namespace OperationsExample.Controllers { - [Route("api/v1/operations")] + [DisableRoutingConvention, Route("api/v1/operations")] public class AtomicOperationsController : JsonApiAtomicOperationsController { - public AtomicOperationsController(IAtomicOperationsProcessor processor) - : base(processor) + public AtomicOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IAtomicOperationsProcessor processor) + : base(options, loggerFactory, processor) { } } diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs new file mode 100644 index 0000000000..cc0cdd279c --- /dev/null +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs @@ -0,0 +1,79 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; +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 BaseJsonApiAtomicOperationsController : CoreJsonApiController + { + private readonly IJsonApiOptions _options; + private readonly IAtomicOperationsProcessor _processor; + private readonly TraceLogWriter _traceWriter; + + protected BaseJsonApiAtomicOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IAtomicOperationsProcessor processor) + { + if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); + + _options = options ?? throw new ArgumentNullException(nameof(options)); + _processor = processor ?? throw new ArgumentNullException(nameof(processor)); + _traceWriter = new TraceLogWriter(loggerFactory); + } + + /// + /// Processes a document with atomic operations and returns their results. + /// + /// + public virtual async Task PostOperationsAsync([FromBody] AtomicOperationsDocument document, + CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new {document}); + + if (document == null) + { + // TODO: @OPS: Should throw NullReferenceException here, but catch this error higher up the call stack (JsonApiReader). + return new StatusCodeResult(422); + } + + if (_options.ValidateModelState) + { + // TODO: @OPS: Add ModelState validation. + } + + var results = await _processor.ProcessAsync(document.Operations, cancellationToken); + + return Ok(new AtomicOperationsDocument + { + Operations = results + }); + } + } +} diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiAtomicOperationsController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiAtomicOperationsController.cs index a8676a5316..8cf3f90a96 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiAtomicOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiAtomicOperationsController.cs @@ -1,66 +1,38 @@ -using System; using System.Threading; using System.Threading.Tasks; using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Controllers { /// - /// A controller to be used for bulk operations as defined in the json:api 1.1 specification + /// 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 BaseJsonApiAtomicOperationsController directly. /// - public class JsonApiAtomicOperationsController : ControllerBase + /// + /// Your project-specific controller should be decorated with the next attributes: + /// + /// + public abstract class JsonApiAtomicOperationsController : BaseJsonApiAtomicOperationsController { - private readonly IAtomicOperationsProcessor _atomicOperationsProcessor; - - /// - /// The processor to handle bulk operations. - /// - public JsonApiAtomicOperationsController(IAtomicOperationsProcessor atomicOperationsProcessor) + protected JsonApiAtomicOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IAtomicOperationsProcessor processor) + : base(options, loggerFactory, processor) { - _atomicOperationsProcessor = atomicOperationsProcessor ?? throw new ArgumentNullException(nameof(atomicOperationsProcessor)); } - /// - /// Bulk endpoint for json:api operations - /// - /// - /// A json:api operations request document - /// - /// Propagates notification that request handling should be canceled. - /// - /// - /// POST /api/v1/operations HTTP/1.1 - /// Content-Type: application/vnd.api+json - /// - /// { - /// "operations": [{ - /// "op": "add", - /// "ref": { - /// "type": "authors" - /// }, - /// "data": { - /// "type": "authors", - /// "attributes": { - /// "name": "jaredcnance" - /// } - /// } - /// }] - /// } - /// - /// + /// [HttpPost] - public virtual async Task PostOperationsAsync([FromBody] AtomicOperationsDocument doc, CancellationToken cancellationToken) + public override async Task PostOperationsAsync([FromBody] AtomicOperationsDocument document, + CancellationToken cancellationToken) { - if (doc == null) return new StatusCodeResult(422); - - var results = await _atomicOperationsProcessor.ProcessAsync(doc.Operations, cancellationToken); - - return Ok(new AtomicOperationsDocument - { - Operations = results - }); + return await base.PostOperationsAsync(document, cancellationToken); } } } 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/IJsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs index 60182fb289..3ea888e4ff 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs @@ -57,7 +57,5 @@ public interface IJsonApiRequest /// Indicates whether this request targets only fetching of data (such as resources and relationships). /// bool IsReadOnly { get; } - - bool IsBulkRequest { get; } } } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index 00f1d09c00..ef1bb3e978 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -59,9 +59,9 @@ public async Task Invoke(HttpContext httpContext, httpContext.RegisterJsonApiRequest(); } - else if (PathIsBulk(routeValues)) + else if (IsAtomicOperationsRequest(routeValues)) { - ((JsonApiRequest)request).IsBulkRequest = true; + SetupAtomicOperationsRequest((JsonApiRequest)request); httpContext.RegisterJsonApiRequest(); } @@ -69,12 +69,6 @@ public async Task Invoke(HttpContext httpContext, await _next(httpContext); } - private static bool PathIsBulk(RouteValueDictionary routeValues) - { - var actionName = (string)routeValues["action"]; - return actionName == "PostOperations"; - } - private static ResourceContext CreatePrimaryResourceContext(RouteValueDictionary routeValues, IControllerResourceMapping controllerResourceMapping, IResourceContextProvider resourceContextProvider) { @@ -261,5 +255,20 @@ private static bool IsRouteForRelationship(RouteValueDictionary routeValues) var actionName = (string)routeValues["action"]; return actionName.EndsWith("Relationship", StringComparison.Ordinal); } + + private static bool IsAtomicOperationsRequest(RouteValueDictionary routeValues) + { + var actionName = (string)routeValues["action"]; + return actionName == "PostOperations"; + } + + private static void SetupAtomicOperationsRequest(JsonApiRequest request) + { + request.IsReadOnly = false; + request.Kind = EndpointKind.AtomicOperations; + + // TODO: @OPS: How should we set BasePath to make link rendering work? + request.BasePath = null; + } } } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs index 2ccadcfcbd..5080c5f9e2 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs @@ -29,8 +29,5 @@ public sealed class JsonApiRequest : IJsonApiRequest /// public bool IsReadOnly { get; set; } - - /// - public bool IsBulkRequest { get; set; } } } diff --git a/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs index ceb7e743d3..cc4513c5ce 100644 --- a/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs @@ -14,7 +14,7 @@ public interface IJsonApiDeserializer /// /// The JSON to be deserialized. /// The resources constructed from the content. - object Deserialize(string body); + object DeserializeDocument(string body); /// /// Deserializes JSON into a and constructs entities diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index 763e4d26b1..d7427500d7 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs @@ -47,21 +47,21 @@ public async Task ReadAsync(InputFormatterContext context) string body = await GetRequestBodyAsync(context.HttpContext.Request.Body); - if (_request.IsBulkRequest) + string url = context.HttpContext.Request.GetEncodedUrl(); + _traceWriter.LogMessage(() => $"Received request at '{url}' with body: <<{body}>>"); + + if (_request.Kind == EndpointKind.AtomicOperations) { var operations = _deserializer.DeserializeOperationsDocument(body); return await InputFormatterResult.SuccessAsync(operations); } - string url = context.HttpContext.Request.GetEncodedUrl(); - _traceWriter.LogMessage(() => $"Received request at '{url}' with body: <<{body}>>"); - object model = null; if (!string.IsNullOrWhiteSpace(body)) { try { - model = _deserializer.Deserialize(body); + model = _deserializer.DeserializeDocument(body); } catch (JsonApiSerializationException exception) { diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index debad50721..85016c3f12 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -36,7 +36,7 @@ public RequestDeserializer( } /// - public object Deserialize(string body) + public object DeserializeDocument(string body) { if (body == null) throw new ArgumentNullException(nameof(body)); diff --git a/test/UnitTests/Models/ResourceConstructionTests.cs b/test/UnitTests/Models/ResourceConstructionTests.cs index 6fca6fc3ec..55c00d0b3b 100644 --- a/test/UnitTests/Models/ResourceConstructionTests.cs +++ b/test/UnitTests/Models/ResourceConstructionTests.cs @@ -49,7 +49,7 @@ public void When_resource_has_default_constructor_it_must_succeed() string content = JsonConvert.SerializeObject(body); // Act - object result = serializer.Deserialize(content); + object result = serializer.DeserializeDocument(content); // Assert Assert.NotNull(result); @@ -78,7 +78,7 @@ public void When_resource_has_default_constructor_that_throws_it_must_fail() string content = JsonConvert.SerializeObject(body); // Act - Action action = () => serializer.Deserialize(content); + Action action = () => serializer.DeserializeDocument(content); // Assert var exception = Assert.Throws(action); @@ -114,7 +114,7 @@ public void When_resource_has_constructor_with_injectable_parameter_it_must_succ string content = JsonConvert.SerializeObject(body); // Act - object result = serializer.Deserialize(content); + object result = serializer.DeserializeDocument(content); // Assert Assert.NotNull(result); @@ -144,7 +144,7 @@ public void When_resource_has_constructor_with_string_parameter_it_must_fail() string content = JsonConvert.SerializeObject(body); // Act - Action action = () => serializer.Deserialize(content); + Action action = () => serializer.DeserializeDocument(content); // Assert var exception = Assert.Throws(action); diff --git a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs b/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs index 7f628073c6..abe76ea3f6 100644 --- a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs +++ b/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs @@ -30,7 +30,7 @@ public void DeserializeAttributes_VariousUpdatedMembers_RegistersTargetedFields( var body = JsonConvert.SerializeObject(content); // Act - _deserializer.Deserialize(body); + _deserializer.DeserializeDocument(body); // Assert Assert.Equal(5, attributesToUpdate.Count); @@ -50,7 +50,7 @@ public void DeserializeRelationships_MultipleDependentRelationships_RegistersUpd var body = JsonConvert.SerializeObject(content); // Act - _deserializer.Deserialize(body); + _deserializer.DeserializeDocument(body); // Assert Assert.Equal(4, relationshipsToUpdate.Count); @@ -70,7 +70,7 @@ public void DeserializeRelationships_MultiplePrincipalRelationships_RegistersUpd var body = JsonConvert.SerializeObject(content); // Act - _deserializer.Deserialize(body); + _deserializer.DeserializeDocument(body); // Assert Assert.Equal(4, relationshipsToUpdate.Count); From 4dc7e4a78005ec0696a463697f09f786bd89e52f Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 25 Nov 2020 22:48:52 +0100 Subject: [PATCH 007/123] Updated json document structure, based on atomic:operations spec --- .../AtomicOperationsProcessor.cs | 112 ++++++------ .../IAtomicOperationsProcessor.cs | 2 +- .../Processors/CreateOperationProcessor.cs | 15 +- .../Processors/IAtomicOperationProcessor.cs | 2 +- .../Processors/RemoveOperationProcessor.cs | 4 +- .../Processors/UpdateOperationProcessor.cs | 5 +- .../AtomicOperationProcessorResolver.cs | 10 +- .../IAtomicOperationProcessorResolver.cs | 6 +- .../BaseJsonApiAtomicOperationsController.cs | 2 +- ...cOperation.cs => AtomicOperationObject.cs} | 8 +- .../Objects/AtomicOperationsDocument.cs | 9 +- .../Objects/AtomicResourceReference.cs | 10 ++ .../Objects/AtomicResultObject.cs | 14 ++ .../Objects/ResourceIdentifierObject.cs | 5 +- .../Objects/ResourceReference.cs | 10 -- test/OperationsExampleTests/Add/AddTests.cs | 162 ++++++++++-------- .../Remove/RemoveTests.cs | 30 ++-- test/OperationsExampleTests/TestFixture.cs | 17 +- .../Transactions/TransactionFailureTests.cs | 32 ++-- .../Update/UpdateTests.cs | 80 +++++---- 20 files changed, 305 insertions(+), 230 deletions(-) rename src/JsonApiDotNetCore/Serialization/Objects/{AtomicOperation.cs => AtomicOperationObject.cs} (80%) create mode 100644 src/JsonApiDotNetCore/Serialization/Objects/AtomicResourceReference.cs create mode 100644 src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/Objects/ResourceReference.cs diff --git a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs index 755361e5de..4559848b05 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs @@ -28,13 +28,13 @@ public AtomicOperationsProcessor(IAtomicOperationProcessorResolver resolver, IJs ITargetedFields targetedFields, IResourceGraph resourceGraph, IEnumerable dbContextResolvers) { + if (dbContextResolvers == null) throw new ArgumentNullException(nameof(dbContextResolvers)); + _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); _request = request ?? throw new ArgumentNullException(nameof(request)); _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); - if (dbContextResolvers == null) throw new ArgumentNullException(nameof(dbContextResolvers)); - var resolvers = dbContextResolvers.ToArray(); if (resolvers.Length != 1) { @@ -45,64 +45,64 @@ public AtomicOperationsProcessor(IAtomicOperationProcessorResolver resolver, IJs _dbContext = resolvers[0].GetContext(); } - public async Task> ProcessAsync(IList operations, CancellationToken cancellationToken) + public async Task> ProcessAsync(IList operations, CancellationToken cancellationToken) { if (operations == null) throw new ArgumentNullException(nameof(operations)); - var outputOps = new List(); - var opIndex = 0; + var results = new List(); + var operationIndex = 0; AtomicOperationCode? lastAttemptedOperation = null; // used for error messages only - using (var transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken)) + await using var transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken); + try { - try + foreach (var operation in operations) { - foreach (var operation in operations) - { - lastAttemptedOperation = operation.Code; - await ProcessOperation(operation, outputOps, cancellationToken); - opIndex++; - } - - await transaction.CommitAsync(cancellationToken); - return outputOps; + lastAttemptedOperation = operation.Code; + await ProcessOperation(operation, results, cancellationToken); + operationIndex++; } - catch (JsonApiException exception) + + await transaction.CommitAsync(cancellationToken); + return results; + } + catch (JsonApiException exception) + { + await transaction.RollbackAsync(cancellationToken); + + throw new JsonApiException(new Error(exception.Error.StatusCode) { - await transaction.RollbackAsync(cancellationToken); - throw new JsonApiException(new Error(exception.Error.StatusCode) - { - Title = "Transaction failed on operation.", - Detail = $"Transaction failed on operation[{opIndex}] ({lastAttemptedOperation})." - }, exception); - } - catch (Exception exception) + Title = "Transaction failed on operation.", + Detail = $"Transaction failed on operation[{operationIndex}] ({lastAttemptedOperation})." + }, exception); + } + catch (Exception exception) + { + await transaction.RollbackAsync(cancellationToken); + + throw new JsonApiException(new Error(HttpStatusCode.InternalServerError) { - await transaction.RollbackAsync(cancellationToken); - throw new JsonApiException(new Error(HttpStatusCode.InternalServerError) - { - Title = "Transaction failed on operation.", - Detail = $"Transaction failed on operation[{opIndex}] ({lastAttemptedOperation}) for an unexpected reason." - }, exception); - } + Title = "Transaction failed on operation.", + Detail = $"Transaction failed on operation[{operationIndex}] ({lastAttemptedOperation}) for an unexpected reason." + }, exception); } } - private async Task ProcessOperation(AtomicOperation inputOperation, List outputOperations, CancellationToken cancellationToken) + private async Task ProcessOperation(AtomicOperationObject operation, List results, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - ReplaceLocalIdsInResourceObject(inputOperation.SingleData, outputOperations); - ReplaceLocalIdsInRef(inputOperation.Ref, outputOperations); + ReplaceLocalIdsInResourceObject(operation.SingleData, results); + ReplaceLocalIdsInRef(operation.Ref, results); string type = null; - if (inputOperation.Code == AtomicOperationCode.Add || inputOperation.Code == AtomicOperationCode.Update) + if (operation.Code == AtomicOperationCode.Add || operation.Code == AtomicOperationCode.Update) { - type = inputOperation.SingleData.Type; + type = operation.SingleData.Type; } - else if (inputOperation.Code == AtomicOperationCode.Remove) + else if (operation.Code == AtomicOperationCode.Remove) { - type = inputOperation.Ref.Type; + type = operation.Ref.Type; } ((JsonApiRequest)_request).PrimaryResource = _resourceGraph.GetResourceContext(type); @@ -110,27 +110,25 @@ private async Task ProcessOperation(AtomicOperation inputOperation, List outputOperations) + private void ReplaceLocalIdsInResourceObject(ResourceObject resourceObject, List results) { if (resourceObject == null) return; // it is strange to me that a top level resource object might use a lid. // by not replacing it, we avoid a case where the first operation is an 'add' with an 'lid' - // and we would be unable to locate the matching 'lid' in 'outputOperations' + // and we would be unable to locate the matching 'lid' in 'results' // // we also create a scenario where I might try to update a resource I just created - // in this case, the 'data.id' will be null, but the 'ref.id' will be replaced by the correct 'id' from 'outputOperations' + // in this case, the 'data.id' will be null, but the 'ref.id' will be replaced by the correct 'id' from 'results' // // if(HasLocalId(resourceObject)) - // resourceObject.Id = GetIdFromLocalId(outputOperations, resourceObject.LocalId); + // resourceObject.Id = GetIdFromLocalId(results, resourceObject.LocalId); if (resourceObject.Relationships != null) { @@ -140,30 +138,30 @@ private void ReplaceLocalIdsInResourceObject(ResourceObject resourceObject, List { foreach (var relationship in relationshipDictionary.Value.ManyData) if (HasLocalId(relationship)) - relationship.Id = GetIdFromLocalId(outputOperations, relationship.LocalId); + relationship.Id = GetIdFromLocalId(results, relationship.LocalId); } else { var relationship = relationshipDictionary.Value.SingleData; if (HasLocalId(relationship)) - relationship.Id = GetIdFromLocalId(outputOperations, relationship.LocalId); + relationship.Id = GetIdFromLocalId(results, relationship.LocalId); } } } } - private void ReplaceLocalIdsInRef(ResourceReference resourceReference, List outputOperations) + private void ReplaceLocalIdsInRef(AtomicResourceReference reference, List results) { - if (resourceReference == null) return; - if (HasLocalId(resourceReference)) - resourceReference.Id = GetIdFromLocalId(outputOperations, resourceReference.LocalId); + if (reference == null) return; + if (HasLocalId(reference)) + reference.Id = GetIdFromLocalId(results, reference.LocalId); } private bool HasLocalId(ResourceIdentifierObject rio) => string.IsNullOrEmpty(rio.LocalId) == false; - private string GetIdFromLocalId(List outputOps, string localId) + private string GetIdFromLocalId(List results, string localId) { - var referencedOp = outputOps.FirstOrDefault(o => o.SingleData.LocalId == localId); + var referencedOp = results.FirstOrDefault(o => o.SingleData.LocalId == localId); if (referencedOp == null) { throw new JsonApiException(new Error(HttpStatusCode.BadRequest) @@ -176,7 +174,7 @@ private string GetIdFromLocalId(List outputOps, string localId) return referencedOp.SingleData.Id; } - private IAtomicOperationProcessor GetOperationsProcessor(AtomicOperation operation) + private IAtomicOperationProcessor GetOperationsProcessor(AtomicOperationObject operation) { switch (operation.Code) { diff --git a/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsProcessor.cs index 1685c1a569..71f405c370 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsProcessor.cs @@ -10,6 +10,6 @@ namespace JsonApiDotNetCore.AtomicOperations /// public interface IAtomicOperationsProcessor { - Task> ProcessAsync(IList operations, CancellationToken cancellationToken); + Task> ProcessAsync(IList operations, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateOperationProcessor.cs index 0f3bb9f103..da57a574b5 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateOperationProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateOperationProcessor.cs @@ -28,20 +28,17 @@ public CreateOperationProcessor(ICreateService service, IJsonApi _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); } - public async Task ProcessAsync(AtomicOperation operation, CancellationToken cancellationToken) + public async Task ProcessAsync(AtomicOperationObject operation, CancellationToken cancellationToken) { var model = (TResource) _deserializer.CreateResourceFromObject(operation.SingleData); - var result = await _service.CreateAsync(model, cancellationToken); - - var operationResult = new AtomicOperation - { - Code = AtomicOperationCode.Add - }; + var newResource = await _service.CreateAsync(model, cancellationToken); ResourceContext resourceContext = _resourceGraph.GetResourceContext(operation.SingleData.Type); - operationResult.Data = - _resourceObjectBuilder.Build(result, resourceContext.Attributes, resourceContext.Relationships); + var operationResult = new AtomicResultObject + { + Data = _resourceObjectBuilder.Build(newResource, resourceContext.Attributes, resourceContext.Relationships) + }; // we need to persist the original request localId so that subsequent operations // can locate the result of this operation by its localId diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IAtomicOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAtomicOperationProcessor.cs index 0a6a5ab400..8bff0e8374 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IAtomicOperationProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAtomicOperationProcessor.cs @@ -9,6 +9,6 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors /// public interface IAtomicOperationProcessor { - Task ProcessAsync(AtomicOperation operation, CancellationToken cancellationToken); + Task ProcessAsync(AtomicOperationObject operation, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveOperationProcessor.cs index f42495914d..2f5cc7bb95 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveOperationProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveOperationProcessor.cs @@ -20,7 +20,7 @@ public RemoveOperationProcessor(IDeleteService service) _service = service ?? throw new ArgumentNullException(nameof(service)); } - public async Task ProcessAsync(AtomicOperation operation, CancellationToken cancellationToken) + public async Task ProcessAsync(AtomicOperationObject operation, CancellationToken cancellationToken) { var stringId = operation.Ref?.Id; if (string.IsNullOrWhiteSpace(stringId)) @@ -34,7 +34,7 @@ public async Task ProcessAsync(AtomicOperation operation, Cance var id = (TId) TypeHelper.ConvertType(stringId, typeof(TId)); await _service.DeleteAsync(id, cancellationToken); - return null; + return new AtomicResultObject(); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateOperationProcessor.cs index 526700a5ee..f3bf64cb4f 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateOperationProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateOperationProcessor.cs @@ -30,7 +30,7 @@ public UpdateOperationProcessor(IUpdateService service, IJsonApi _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); } - public async Task ProcessAsync(AtomicOperation operation, CancellationToken cancellationToken) + public async Task ProcessAsync(AtomicOperationObject operation, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(operation?.SingleData?.Id)) { @@ -52,9 +52,8 @@ public async Task ProcessAsync(AtomicOperation operation, Cance data = _resourceObjectBuilder.Build(result, resourceContext.Attributes, resourceContext.Relationships); } - return new AtomicOperation + return new AtomicResultObject { - Code = AtomicOperationCode.Update, Data = data }; } diff --git a/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs b/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs index ecdf4afc53..2d1a8e103b 100644 --- a/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs @@ -21,26 +21,26 @@ public AtomicOperationProcessorResolver(IGenericServiceFactory genericServiceFac } /// - public IAtomicOperationProcessor ResolveCreateProcessor(AtomicOperation operation) + public IAtomicOperationProcessor ResolveCreateProcessor(AtomicOperationObject operation) { return Resolve(operation, typeof(ICreateOperationProcessor<,>)); } /// - public IAtomicOperationProcessor ResolveRemoveProcessor(AtomicOperation operation) + public IAtomicOperationProcessor ResolveRemoveProcessor(AtomicOperationObject operation) { return Resolve(operation, typeof(IRemoveOperationProcessor<,>)); } /// - public IAtomicOperationProcessor ResolveUpdateProcessor(AtomicOperation operation) + public IAtomicOperationProcessor ResolveUpdateProcessor(AtomicOperationObject operation) { return Resolve(operation, typeof(IUpdateOperationProcessor<,>)); } - private IAtomicOperationProcessor Resolve(AtomicOperation atomicOperation, Type processorInterface) + private IAtomicOperationProcessor Resolve(AtomicOperationObject atomicOperationObject, Type processorInterface) { - var resourceName = atomicOperation.GetResourceTypeName(); + var resourceName = atomicOperationObject.GetResourceTypeName(); var resourceContext = GetResourceContext(resourceName); return _genericServiceFactory.Get(processorInterface, diff --git a/src/JsonApiDotNetCore/Configuration/IAtomicOperationProcessorResolver.cs b/src/JsonApiDotNetCore/Configuration/IAtomicOperationProcessorResolver.cs index 6cfb4643a8..d99bd25701 100644 --- a/src/JsonApiDotNetCore/Configuration/IAtomicOperationProcessorResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/IAtomicOperationProcessorResolver.cs @@ -11,16 +11,16 @@ public interface IAtomicOperationProcessorResolver /// /// Resolves a compatible . /// - IAtomicOperationProcessor ResolveCreateProcessor(AtomicOperation operation); + IAtomicOperationProcessor ResolveCreateProcessor(AtomicOperationObject operation); /// /// Resolves a compatible . /// - IAtomicOperationProcessor ResolveRemoveProcessor(AtomicOperation operation); + IAtomicOperationProcessor ResolveRemoveProcessor(AtomicOperationObject operation); /// /// Resolves a compatible . /// - IAtomicOperationProcessor ResolveUpdateProcessor(AtomicOperation operation); + IAtomicOperationProcessor ResolveUpdateProcessor(AtomicOperationObject operation); } } diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs index cc0cdd279c..147f388cb5 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs @@ -72,7 +72,7 @@ public virtual async Task PostOperationsAsync([FromBody] AtomicOp return Ok(new AtomicOperationsDocument { - Operations = results + Results = results }); } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperation.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs similarity index 80% rename from src/JsonApiDotNetCore/Serialization/Objects/AtomicOperation.cs rename to src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs index 5113a3921b..8fb1c66295 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperation.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs @@ -2,11 +2,13 @@ using System.Linq; using Newtonsoft.Json; using Newtonsoft.Json.Converters; -using Newtonsoft.Json.Linq; namespace JsonApiDotNetCore.Serialization.Objects { - public class AtomicOperation : ExposableData + /// + /// https://jsonapi.org/ext/atomic/#operation-objects + /// + public sealed class AtomicOperationObject : ExposableData { [JsonProperty("meta", NullValueHandling = NullValueHandling.Ignore)] public Dictionary Meta { get; set; } @@ -15,7 +17,7 @@ public class AtomicOperation : ExposableData public AtomicOperationCode Code { get; set; } [JsonProperty("ref", NullValueHandling = NullValueHandling.Ignore)] - public ResourceReference Ref { get; set; } + public AtomicResourceReference 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 index dc813f9b6e..6b66075bfe 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationsDocument.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationsDocument.cs @@ -6,9 +6,12 @@ namespace JsonApiDotNetCore.Serialization.Objects /// /// https://jsonapi.org/ext/atomic/#document-structure /// - public class AtomicOperationsDocument + public sealed class AtomicOperationsDocument { - [JsonProperty("operations")] - public IList Operations { get; set; } + [JsonProperty("atomic:operations", NullValueHandling = NullValueHandling.Ignore)] + public IList Operations { get; set; } + + [JsonProperty("atomic:results", NullValueHandling = NullValueHandling.Ignore)] + public IList Results { get; set; } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicResourceReference.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicResourceReference.cs new file mode 100644 index 0000000000..3acb367a14 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicResourceReference.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Serialization.Objects +{ + public sealed class AtomicResourceReference : ResourceIdentifierObject + { + [JsonProperty("relationship", NullValueHandling = NullValueHandling.Ignore)] + public string Relationship { get; set; } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs new file mode 100644 index 0000000000..168ce9ff66 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Serialization.Objects +{ + /// + /// 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 b71ab5045d..e82e8d43e5 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs @@ -13,6 +13,9 @@ public class ResourceIdentifierObject [JsonProperty("lid", NullValueHandling = NullValueHandling.Ignore, Order = -2)] public string LocalId { get; set; } - public override string ToString() => $"(type: {Type}, id: {Id}, lid: {LocalId})"; + public override string ToString() + { + return $"(type: {Type}, id: {Id}, lid: {LocalId})"; + } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ResourceReference.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceReference.cs deleted file mode 100644 index 95ec9c4e3f..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Objects/ResourceReference.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.Serialization.Objects -{ - public class ResourceReference : ResourceIdentifierObject - { - [JsonProperty("relationship")] - public string Relationship { get; set; } - } -} diff --git a/test/OperationsExampleTests/Add/AddTests.cs b/test/OperationsExampleTests/Add/AddTests.cs index ad91d2d937..9a3b45e4fb 100644 --- a/test/OperationsExampleTests/Add/AddTests.cs +++ b/test/OperationsExampleTests/Add/AddTests.cs @@ -29,12 +29,16 @@ public async Task Can_Create_Author() var author = AuthorFactory.Get(); var content = new { - operations = new[] { - new { + atomic__operations = new[] + { + new + { op = "add", - data = new { + data = new + { type = "authors", - attributes = new { + attributes = new + { firstName = author.FirstName } } @@ -49,7 +53,7 @@ public async Task Can_Create_Author() Assert.NotNull(response); _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - var id = int.Parse(data.Operations.Single().SingleData.Id); + var id = int.Parse(data.Results.Single().SingleData.Id); var lastAuthor = await _fixture.Context.AuthorDifferentDbContextName.SingleAsync(a => a.Id == id); Assert.Equal(author.FirstName, lastAuthor.FirstName); } @@ -62,24 +66,24 @@ public async Task Can_Create_Authors() var authors = AuthorFactory.Get(expectedCount); var content = new { - operations = new List() + atomic__operations = new List() }; for (int i = 0; i < expectedCount; i++) { - content.operations.Add( - new - { - op = "add", - data = new - { - type = "authors", - attributes = new - { - firstName = authors[i].FirstName - } - } - } + content.atomic__operations.Add( + new + { + op = "add", + data = new + { + type = "authors", + attributes = new + { + firstName = authors[i].FirstName + } + } + } ); } @@ -89,11 +93,11 @@ public async Task Can_Create_Authors() // assert Assert.NotNull(response); _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - Assert.Equal(expectedCount, data.Operations.Count); + Assert.Equal(expectedCount, data.Results.Count); for (int i = 0; i < expectedCount; i++) { - var dataObject = data.Operations[i].SingleData; + var dataObject = data.Results[i].SingleData; var author = _fixture.Context.AuthorDifferentDbContextName.Single(a => a.Id == int.Parse(dataObject.Id)); Assert.Equal(authors[i].FirstName, author.FirstName); } @@ -110,22 +114,26 @@ public async Task Can_Create_Article_With_Existing_Author() context.AuthorDifferentDbContextName.Add(author); await context.SaveChangesAsync(); - - //const string authorLocalId = "author-1"; - var content = new { - operations = new object[] { - new { + atomic__operations = new object[] + { + new + { op = "add", - data = new { + data = new + { type = "articles", - attributes = new { + attributes = new + { caption = article.Caption }, - relationships = new { - author = new { - data = new { + relationships = new + { + author = new + { + data = new + { type = "authors", id = author.Id } @@ -142,13 +150,13 @@ public async Task Can_Create_Article_With_Existing_Author() // assert Assert.NotNull(response); _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - Assert.Single(data.Operations); + Assert.Single(data.Results); var lastAuthor = await context.AuthorDifferentDbContextName .Include(a => a.Articles) .SingleAsync(a => a.Id == author.Id); - var articleOperationResult = data.Operations[0]; + var articleOperationResult = data.Results[0]; // author validation: sanity checks Assert.NotNull(lastAuthor); @@ -172,35 +180,35 @@ public async Task Can_Create_Articles_With_Existing_Author() var content = new { - operations = new List() + atomic__operations = new List() }; for (int i = 0; i < expectedCount; i++) { - content.operations.Add( - new - { - op = "add", - data = new - { - type = "articles", - attributes = new - { - caption = articles[i].Caption - }, - relationships = new - { - author = new - { - data = new - { - type = "authors", - id = author.Id - } - } - } - } - } + content.atomic__operations.Add( + new + { + op = "add", + data = new + { + type = "articles", + attributes = new + { + caption = articles[i].Caption + }, + relationships = new + { + author = new + { + data = new + { + type = "authors", + id = author.Id + } + } + } + } + } ); } @@ -210,7 +218,7 @@ public async Task Can_Create_Articles_With_Existing_Author() // assert Assert.NotNull(response); _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - Assert.Equal(expectedCount, data.Operations.Count); + Assert.Equal(expectedCount, data.Results.Count); // author validation: sanity checks var lastAuthor = _fixture.Context.AuthorDifferentDbContextName.Include(a => a.Articles).Single(a => a.Id == author.Id); @@ -236,27 +244,37 @@ public async Task Can_Create_Author_With_Article_Using_LocalId() var content = new { - operations = new object[] { - new { + atomic__operations = new object[] + { + new + { op = "add", - data = new { + data = new + { lid = authorLocalId, type = "authors", - attributes = new { + attributes = new + { firstName = author.FirstName }, } }, - new { + new + { op = "add", - data = new { + data = new + { type = "articles", - attributes = new { + attributes = new + { caption = article.Caption }, - relationships = new { - author = new { - data = new { + relationships = new + { + author = new + { + data = new + { type = "authors", lid = authorLocalId } @@ -273,14 +291,14 @@ public async Task Can_Create_Author_With_Article_Using_LocalId() // assert Assert.NotNull(response); _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - Assert.Equal(2, data.Operations.Count); + Assert.Equal(2, data.Results.Count); - var authorOperationResult = data.Operations[0]; + var authorOperationResult = data.Results[0]; var id = int.Parse(authorOperationResult.SingleData.Id); var lastAuthor = await _fixture.Context.AuthorDifferentDbContextName .Include(a => a.Articles) .SingleAsync(a => a.Id == id); - var articleOperationResult = data.Operations[1]; + var articleOperationResult = data.Results[1]; // author validation Assert.Equal(authorLocalId, authorOperationResult.SingleData.LocalId); diff --git a/test/OperationsExampleTests/Remove/RemoveTests.cs b/test/OperationsExampleTests/Remove/RemoveTests.cs index 78671106e1..fa8d517ec2 100644 --- a/test/OperationsExampleTests/Remove/RemoveTests.cs +++ b/test/OperationsExampleTests/Remove/RemoveTests.cs @@ -31,10 +31,12 @@ public async Task Can_Remove_Author() var content = new { - operations = new[] { - new Dictionary { - { "op", "remove"}, - { "ref", new { type = "authors", id = author.StringId } } + atomic__operations = new[] + { + new Dictionary + { + {"op", "remove"}, + {"ref", new {type = "authors", id = author.StringId}} } } }; @@ -46,7 +48,7 @@ public async Task Can_Remove_Author() Assert.NotNull(response); Assert.NotNull(data); _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - Assert.Empty(data.Operations); + Assert.Single(data.Results); Assert.Null(_fixture.Context.AuthorDifferentDbContextName.SingleOrDefault(a => a.Id == author.Id)); } @@ -62,16 +64,19 @@ public async Task Can_Remove_Authors() var content = new { - operations = new List() + atomic__operations = new List() }; for (int i = 0; i < count; i++) - content.operations.Add( - new Dictionary { - { "op", "remove"}, - { "ref", new { type = "authors", id = authors[i].StringId } } + { + content.atomic__operations.Add( + new Dictionary + { + {"op", "remove"}, + {"ref", new {type = "authors", id = authors[i].StringId}} } ); + } // act var (response, data) = await _fixture.PostAsync("api/v1/operations", content); @@ -80,10 +85,13 @@ public async Task Can_Remove_Authors() Assert.NotNull(response); Assert.NotNull(data); _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - Assert.Empty(data.Operations); + Assert.Equal(count, data.Results.Count); for (int i = 0; i < count; i++) + { + Assert.Null(data.Results[i].Data); Assert.Null(_fixture.Context.AuthorDifferentDbContextName.SingleOrDefault(a => a.Id == authors[i].Id)); + } } } } diff --git a/test/OperationsExampleTests/TestFixture.cs b/test/OperationsExampleTests/TestFixture.cs index 17b345d9de..1248651608 100644 --- a/test/OperationsExampleTests/TestFixture.cs +++ b/test/OperationsExampleTests/TestFixture.cs @@ -3,6 +3,7 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Tasks; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCoreExample.Data; using Microsoft.AspNetCore; @@ -62,11 +63,17 @@ public void Dispose() private async Task PostAsync(string route, object data) { - var httpMethod = HttpMethod.Post; - var request = new HttpRequestMessage(httpMethod, route); - request.Content = new StringContent(JsonConvert.SerializeObject(data)); - request.Content.Headers.ContentLength = 1; - request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/vnd.api+json"); + var request = new HttpRequestMessage(HttpMethod.Post, route); + + if (data != null) + { + var requestBody = JsonConvert.SerializeObject(data); + requestBody = requestBody.Replace("__", ":"); + + request.Content = new StringContent(requestBody); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + } + return await _client.SendAsync(request); } } diff --git a/test/OperationsExampleTests/Transactions/TransactionFailureTests.cs b/test/OperationsExampleTests/Transactions/TransactionFailureTests.cs index 4518ab5cd3..c8668b5144 100644 --- a/test/OperationsExampleTests/Transactions/TransactionFailureTests.cs +++ b/test/OperationsExampleTests/Transactions/TransactionFailureTests.cs @@ -35,31 +35,41 @@ public async Task Cannot_Create_Author_If_Article_Creation_Fails() var content = new { - operations = new object[] { - new { + atomic__operations = new object[] + { + new + { op = "add", - data = new { + data = new + { type = "authors", - attributes = new { + attributes = new + { firstName = author.FirstName } } }, - new { + new + { op = "add", - data = new { + data = new + { type = "articles", - attributes = new { + attributes = new + { caption = article.Caption }, - relationships = new { - author = new { - data = new { + relationships = new + { + author = new + { + data = new + { type = "authors", id = 99999999 } } - } + } } } } diff --git a/test/OperationsExampleTests/Update/UpdateTests.cs b/test/OperationsExampleTests/Update/UpdateTests.cs index 2f2d45d0e8..95c762138f 100644 --- a/test/OperationsExampleTests/Update/UpdateTests.cs +++ b/test/OperationsExampleTests/Update/UpdateTests.cs @@ -32,21 +32,29 @@ public async Task Can_Update_Author() var content = new { - operations = new[] { - new Dictionary { - { "op", "update" }, - { "ref", new { - type = "authors", - id = author.Id, - } }, - { "data", new { - type = "authors", - id = author.Id, - attributes = new + atomic__operations = new[] + { + new Dictionary + { + {"op", "update"}, + { + "ref", new { - firstName = updates.FirstName + type = "authors", + id = author.Id, } - } }, + }, + { + "data", new + { + type = "authors", + id = author.Id, + attributes = new + { + firstName = updates.FirstName + } + } + }, } } }; @@ -58,10 +66,10 @@ public async Task Can_Update_Author() Assert.NotNull(response); _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); Assert.NotNull(data); - Assert.Single(data.Operations); + Assert.Single(data.Results); + Assert.Null(data.Results[0].Data); - Assert.Equal(AtomicOperationCode.Update, data.Operations.Single().Code); - Assert.Null(data.Operations.Single().SingleData); + Assert.Null(data.Results.Single().SingleData); } [Fact] @@ -78,25 +86,34 @@ public async Task Can_Update_Authors() var content = new { - operations = new List() + atomic__operations = new List() }; for (int i = 0; i < count; i++) - content.operations.Add(new Dictionary { - { "op", "update" }, - { "ref", new { - type = "authors", - id = authors[i].Id, - } }, - { "data", new { - type = "authors", - id = authors[i].Id, - attributes = new + { + content.atomic__operations.Add(new Dictionary + { + {"op", "update"}, + { + "ref", new { - firstName = updates[i].FirstName + type = "authors", + id = authors[i].Id, + } + }, + { + "data", new + { + type = "authors", + id = authors[i].Id, + attributes = new + { + firstName = updates[i].FirstName + } } - } }, + }, }); + } // act var (response, data) = await _fixture.PostAsync("api/v1/operations", content); @@ -105,12 +122,11 @@ public async Task Can_Update_Authors() Assert.NotNull(response); _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); Assert.NotNull(data); - Assert.Equal(count, data.Operations.Count); + Assert.Equal(count, data.Results.Count); for (int i = 0; i < count; i++) { - Assert.Equal(AtomicOperationCode.Update, data.Operations[i].Code); - Assert.Null(data.Operations[i].SingleData); + Assert.Null(data.Results[i].SingleData); } } } From ee5271926851ac889b5b67458a76ae943e47d592 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 25 Nov 2020 22:56:47 +0100 Subject: [PATCH 008/123] Simplified processor resolver --- .../AtomicOperationsProcessor.cs | 21 +------------- .../AtomicOperationProcessorResolver.cs | 28 +++++++++++++++---- .../IAtomicOperationProcessorResolver.cs | 16 ++--------- 3 files changed, 26 insertions(+), 39 deletions(-) diff --git a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs index 4559848b05..0bad846524 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs @@ -110,7 +110,7 @@ private async Task ProcessOperation(AtomicOperationObject operation, List results, string localId return referencedOp.SingleData.Id; } - - private IAtomicOperationProcessor GetOperationsProcessor(AtomicOperationObject operation) - { - switch (operation.Code) - { - case AtomicOperationCode.Add: - return _resolver.ResolveCreateProcessor(operation); - case AtomicOperationCode.Remove: - return _resolver.ResolveRemoveProcessor(operation); - case AtomicOperationCode.Update: - return _resolver.ResolveUpdateProcessor(operation); - default: - throw new JsonApiException(new Error(HttpStatusCode.BadRequest) - { - Title = "Invalid operation code.", - Detail = $"'{operation.Code}' is not a valid operation code." - }); - } - } } } diff --git a/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs b/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs index 2d1a8e103b..6a57300052 100644 --- a/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs @@ -12,7 +12,6 @@ public class AtomicOperationProcessorResolver : IAtomicOperationProcessorResolve private readonly IGenericServiceFactory _genericServiceFactory; private readonly IResourceContextProvider _resourceContextProvider; - /// public AtomicOperationProcessorResolver(IGenericServiceFactory genericServiceFactory, IResourceContextProvider resourceContextProvider) { @@ -21,19 +20,34 @@ public AtomicOperationProcessorResolver(IGenericServiceFactory genericServiceFac } /// - public IAtomicOperationProcessor ResolveCreateProcessor(AtomicOperationObject operation) + public IAtomicOperationProcessor ResolveProcessor(AtomicOperationObject operation) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + + switch (operation.Code) + { + case AtomicOperationCode.Add: + return ResolveCreateProcessor(operation); + case AtomicOperationCode.Remove: + return ResolveRemoveProcessor(operation); + case AtomicOperationCode.Update: + return ResolveUpdateProcessor(operation); + } + + throw new InvalidOperationException($"Atomic operation code '{operation.Code}' is invalid."); + } + + private IAtomicOperationProcessor ResolveCreateProcessor(AtomicOperationObject operation) { return Resolve(operation, typeof(ICreateOperationProcessor<,>)); } - /// - public IAtomicOperationProcessor ResolveRemoveProcessor(AtomicOperationObject operation) + private IAtomicOperationProcessor ResolveRemoveProcessor(AtomicOperationObject operation) { return Resolve(operation, typeof(IRemoveOperationProcessor<,>)); } - /// - public IAtomicOperationProcessor ResolveUpdateProcessor(AtomicOperationObject operation) + private IAtomicOperationProcessor ResolveUpdateProcessor(AtomicOperationObject operation) { return Resolve(operation, typeof(IUpdateOperationProcessor<,>)); } @@ -53,6 +67,8 @@ private ResourceContext GetResourceContext(string resourceName) var resourceContext = _resourceContextProvider.GetResourceContext(resourceName); if (resourceContext == null) { + // TODO: @OPS: Should have validated this earlier in the call stack. + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) { Title = "Unsupported resource type.", diff --git a/src/JsonApiDotNetCore/Configuration/IAtomicOperationProcessorResolver.cs b/src/JsonApiDotNetCore/Configuration/IAtomicOperationProcessorResolver.cs index d99bd25701..0a9dbd2f44 100644 --- a/src/JsonApiDotNetCore/Configuration/IAtomicOperationProcessorResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/IAtomicOperationProcessorResolver.cs @@ -4,23 +4,13 @@ namespace JsonApiDotNetCore.Configuration { /// - /// Used to resolve the registered at runtime, based on the resource type in the operation. + /// Used to resolve a compatible at runtime, based on the operation code. /// public interface IAtomicOperationProcessorResolver { /// - /// Resolves a compatible . + /// Resolves a compatible . /// - IAtomicOperationProcessor ResolveCreateProcessor(AtomicOperationObject operation); - - /// - /// Resolves a compatible . - /// - IAtomicOperationProcessor ResolveRemoveProcessor(AtomicOperationObject operation); - - /// - /// Resolves a compatible . - /// - IAtomicOperationProcessor ResolveUpdateProcessor(AtomicOperationObject operation); + IAtomicOperationProcessor ResolveProcessor(AtomicOperationObject operation); } } From fc006204751e5870f729973662e905c3f962f14c Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 26 Nov 2020 12:42:47 +0100 Subject: [PATCH 009/123] Converted atomic:operations Add tests --- .../Controllers/AtomicOperationsController.cs | 19 ++ .../IntegrationTestContext.cs | 2 + .../Add/AtomicAddResourceTests.cs | 212 ++++++++++++ ...icAddResourceWithToOneRelationshipTests.cs | 287 ++++++++++++++++ .../AtomicOperations/MusicTrack.cs | 30 ++ .../AtomicOperations/OperationsDbContext.cs | 23 ++ .../AtomicOperations/OperationsFakers.cs | 38 +++ .../AtomicOperations/Performer.cs | 15 + .../AtomicOperations/Playlist.cs | 21 ++ .../AtomicOperations/PlaylistMusicTrack.cs | 11 + .../AtomicOperations/RecordCompany.cs | 18 + .../ObjectAssertionsExtensions.cs | 12 + test/OperationsExampleTests/Add/AddTests.cs | 313 ------------------ 13 files changed, 688 insertions(+), 313 deletions(-) create mode 100644 src/Examples/JsonApiDotNetCoreExample/Controllers/AtomicOperationsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Add/AtomicAddResourceTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Add/AtomicAddResourceWithToOneRelationshipTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsFakers.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Performer.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Playlist.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PlaylistMusicTrack.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/RecordCompany.cs delete mode 100644 test/OperationsExampleTests/Add/AddTests.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/AtomicOperationsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/AtomicOperationsController.cs new file mode 100644 index 0000000000..f30893a4c1 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/AtomicOperationsController.cs @@ -0,0 +1,19 @@ +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Controllers.Annotations; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExample.Controllers +{ + [DisableRoutingConvention, Route("/operations")] + public class AtomicOperationsController : JsonApiAtomicOperationsController + { + public AtomicOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IAtomicOperationsProcessor processor) + : base(options, loggerFactory, processor) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs index cb12fc2c09..4982bdf302 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs @@ -145,6 +145,8 @@ public async Task RunOnDatabaseAsync(Func asyncAction) if (!string.IsNullOrEmpty(requestText)) { + requestText = requestText.Replace("atomic__", "atomic:"); + request.Content = new StringContent(requestText); if (contentType != null) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Add/AtomicAddResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Add/AtomicAddResourceTests.cs new file mode 100644 index 0000000000..74df9a16cc --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Add/AtomicAddResourceTests.cs @@ -0,0 +1,212 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Add +{ + public sealed class AtomicAddResourceTests + : IClassFixture, OperationsDbContext>> + { + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicAddResourceTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + }); + } + + [Fact] + public async Task Can_add_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.ExecutePostAsync(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); + + 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_add_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.ExecutePostAsync(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)); + + 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_add_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.ExecutePostAsync(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"].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); + } + + 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); + } + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Add/AtomicAddResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Add/AtomicAddResourceWithToOneRelationshipTests.cs new file mode 100644 index 0000000000..76bd88e7da --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Add/AtomicAddResourceWithToOneRelationshipTests.cs @@ -0,0 +1,287 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Add +{ + public sealed class AtomicAddResourceWithToOneRelationshipTests + : IClassFixture, OperationsDbContext>> + { + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicAddResourceWithToOneRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + }); + } + + [Fact] + public async Task Can_add_resource_with_ToOne_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 + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(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(newTrackTitle); + + 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.Title.Should().Be(newTrackTitle); + + trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); + }); + } + + [Fact] + public async Task Can_add_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.ExecutePostAsync(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 Can_add_resource_with_ToOne_relationship_using_local_ID() + { + // Arrange + var newCompany = _fakers.RecordCompany.Generate(); + var newTrack = _fakers.MusicTrack.Generate(); + + 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 = newTrack.Title + }, + relationships = new + { + ownedBy = new + { + data = new + { + type = "recordCompanies", + lid = companyLocalId + } + } + } + } + } + } + }; + + var route = "/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(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"); + // TODO: @OPS: responseDocument.Results[0].SingleData.LocalId.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.LocalId.Should().BeNull(); + responseDocument.Results[1].SingleData.Attributes["title"].Should().Be(newTrack.Title); + + 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(newTrack.Title); + + trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); + trackInDatabase.OwnedBy.Name.Should().Be(newCompany.Name); + trackInDatabase.OwnedBy.CountryOfResidence.Should().Be(newCompany.CountryOfResidence); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs new file mode 100644 index 0000000000..016205de3e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs @@ -0,0 +1,30 @@ +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 + { + [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 RecordCompany OwnedBy { get; set; } + + public IList Performers { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs new file mode 100644 index 0000000000..1aba049a0e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations +{ + public sealed class OperationsDbContext : DbContext + { + public DbSet Playlists { get; set; } + public DbSet MusicTracks { 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}); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsFakers.cs new file mode 100644 index 0000000000..2a335cf6a4 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsFakers.cs @@ -0,0 +1,38 @@ +using System; +using Bogus; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations +{ + internal sealed class OperationsFakers : FakerContainer + { + 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> _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 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..a89adb295b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Playlist.cs @@ -0,0 +1,21 @@ +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] + [HasManyThrough(nameof(PlaylistMusicTracks))] + public IList MusicTracks { 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..391c624ec4 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PlaylistMusicTrack.cs @@ -0,0 +1,11 @@ +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations +{ + public sealed class PlaylistMusicTrack + { + public long PlaylistId { get; set; } + public Playlist Playlist { get; set; } + + public long MusicTrackId { get; set; } + public MusicTrack MusicTrack { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/RecordCompany.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/RecordCompany.cs new file mode 100644 index 0000000000..2b79154d79 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/RecordCompany.cs @@ -0,0 +1,18 @@ +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; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs index c4ab87ea6d..f85c19f489 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs @@ -24,5 +24,17 @@ public static void BeCloseTo(this ObjectAssertions source, DateTimeOffset? expec value.Should().BeCloseTo(expected.Value, because: because, becauseArgs: becauseArgs); } } + + /// + /// Used to assert on a column, whose value is returned as in json:api response body. + /// + public static void BeApproximately(this ObjectAssertions source, decimal expected, decimal precision, string because = "", + params object[] becauseArgs) + { + // We lose a little bit of precision on roundtrip through PostgreSQL database. + + var value = (decimal) (double) source.Subject; + value.Should().BeApproximately(expected, precision, because, becauseArgs); + } } } diff --git a/test/OperationsExampleTests/Add/AddTests.cs b/test/OperationsExampleTests/Add/AddTests.cs deleted file mode 100644 index 9a3b45e4fb..0000000000 --- a/test/OperationsExampleTests/Add/AddTests.cs +++ /dev/null @@ -1,313 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.EntityFrameworkCore; -using OperationsExample; -using OperationsExampleTests.Factories; -using Xunit; - -namespace OperationsExampleTests.Add -{ - [Collection("WebHostCollection")] - public class AddTests - { - private readonly TestFixture _fixture; - private readonly Faker _faker = new Faker(); - - public AddTests(TestFixture fixture) - { - _fixture = fixture; - } - - [Fact] - public async Task Can_Create_Author() - { - // arrange - var author = AuthorFactory.Get(); - var content = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "authors", - attributes = new - { - firstName = author.FirstName - } - } - } - } - }; - - // act - var (response, data) = await _fixture.PostAsync("api/v1/operations", content); - - // assert - Assert.NotNull(response); - _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - - var id = int.Parse(data.Results.Single().SingleData.Id); - var lastAuthor = await _fixture.Context.AuthorDifferentDbContextName.SingleAsync(a => a.Id == id); - Assert.Equal(author.FirstName, lastAuthor.FirstName); - } - - [Fact] - public async Task Can_Create_Authors() - { - // arrange - var expectedCount = _faker.Random.Int(1, 10); - var authors = AuthorFactory.Get(expectedCount); - var content = new - { - atomic__operations = new List() - }; - - for (int i = 0; i < expectedCount; i++) - { - content.atomic__operations.Add( - new - { - op = "add", - data = new - { - type = "authors", - attributes = new - { - firstName = authors[i].FirstName - } - } - } - ); - } - - // act - var (response, data) = await _fixture.PostAsync("api/v1/operations", content); - - // assert - Assert.NotNull(response); - _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - Assert.Equal(expectedCount, data.Results.Count); - - for (int i = 0; i < expectedCount; i++) - { - var dataObject = data.Results[i].SingleData; - var author = _fixture.Context.AuthorDifferentDbContextName.Single(a => a.Id == int.Parse(dataObject.Id)); - Assert.Equal(authors[i].FirstName, author.FirstName); - } - } - - [Fact] - public async Task Can_Create_Article_With_Existing_Author() - { - // arrange - var context = _fixture.Context; - var author = AuthorFactory.Get(); - var article = ArticleFactory.Get(); - - context.AuthorDifferentDbContextName.Add(author); - await context.SaveChangesAsync(); - - var content = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "articles", - attributes = new - { - caption = article.Caption - }, - relationships = new - { - author = new - { - data = new - { - type = "authors", - id = author.Id - } - } - } - } - } - } - }; - - // act - var (response, data) = await _fixture.PostAsync("api/v1/operations", content); - - // assert - Assert.NotNull(response); - _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - Assert.Single(data.Results); - - - var lastAuthor = await context.AuthorDifferentDbContextName - .Include(a => a.Articles) - .SingleAsync(a => a.Id == author.Id); - var articleOperationResult = data.Results[0]; - - // author validation: sanity checks - Assert.NotNull(lastAuthor); - Assert.Equal(author.FirstName, lastAuthor.FirstName); - - //// article validation - Assert.Single(lastAuthor.Articles); - Assert.Equal(article.Caption, lastAuthor.Articles[0].Caption); - Assert.Equal(articleOperationResult.SingleData.Id, lastAuthor.Articles[0].StringId); - } - - [Fact] - public async Task Can_Create_Articles_With_Existing_Author() - { - // arrange - var author = AuthorFactory.Get(); - _fixture.Context.AuthorDifferentDbContextName.Add(author); - await _fixture.Context.SaveChangesAsync(); - var expectedCount = _faker.Random.Int(1, 10); - var articles = ArticleFactory.Get(expectedCount); - - var content = new - { - atomic__operations = new List() - }; - - for (int i = 0; i < expectedCount; i++) - { - content.atomic__operations.Add( - new - { - op = "add", - data = new - { - type = "articles", - attributes = new - { - caption = articles[i].Caption - }, - relationships = new - { - author = new - { - data = new - { - type = "authors", - id = author.Id - } - } - } - } - } - ); - } - - // act - var (response, data) = await _fixture.PostAsync("api/v1/operations", content); - - // assert - Assert.NotNull(response); - _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - Assert.Equal(expectedCount, data.Results.Count); - - // author validation: sanity checks - var lastAuthor = _fixture.Context.AuthorDifferentDbContextName.Include(a => a.Articles).Single(a => a.Id == author.Id); - Assert.NotNull(lastAuthor); - Assert.Equal(author.FirstName, lastAuthor.FirstName); - - // articles validation - Assert.True(lastAuthor.Articles.Count == expectedCount); - for (int i = 0; i < expectedCount; i++) - { - var article = articles[i]; - Assert.NotNull(lastAuthor.Articles.FirstOrDefault(a => a.Caption == article.Caption)); - } - } - - [Fact] - public async Task Can_Create_Author_With_Article_Using_LocalId() - { - // arrange - var author = AuthorFactory.Get(); - var article = ArticleFactory.Get(); - const string authorLocalId = "author-1"; - - var content = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - lid = authorLocalId, - type = "authors", - attributes = new - { - firstName = author.FirstName - }, - } - }, - new - { - op = "add", - data = new - { - type = "articles", - attributes = new - { - caption = article.Caption - }, - relationships = new - { - author = new - { - data = new - { - type = "authors", - lid = authorLocalId - } - } - } - } - } - } - }; - - // act - var (response, data) = await _fixture.PostAsync("api/v1/operations", content); - - // assert - Assert.NotNull(response); - _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - Assert.Equal(2, data.Results.Count); - - var authorOperationResult = data.Results[0]; - var id = int.Parse(authorOperationResult.SingleData.Id); - var lastAuthor = await _fixture.Context.AuthorDifferentDbContextName - .Include(a => a.Articles) - .SingleAsync(a => a.Id == id); - var articleOperationResult = data.Results[1]; - - // author validation - Assert.Equal(authorLocalId, authorOperationResult.SingleData.LocalId); - Assert.Equal(author.FirstName, lastAuthor.FirstName); - - // article validation - Assert.Single(lastAuthor.Articles); - Assert.Equal(article.Caption, lastAuthor.Articles[0].Caption); - Assert.Equal(articleOperationResult.SingleData.Id, lastAuthor.Articles[0].StringId); - } - } -} From 9d534e42e14f7da4ca4ed3e7725275d5da746fc5 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 26 Nov 2020 13:25:58 +0100 Subject: [PATCH 010/123] Converted atomic:operations Remove tests --- .../Controllers/AtomicOperationsController.cs | 2 +- .../AtomicOperationsProcessor.cs | 1 - .../AtomicCreateResourceTests.cs} | 18 +-- ...eateResourceWithToOneRelationshipTests.cs} | 18 +-- .../Deleting/AtomicDeleteResourceTests.cs | 137 ++++++++++++++++++ .../Remove/RemoveTests.cs | 97 ------------- 6 files changed, 156 insertions(+), 117 deletions(-) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/{Add/AtomicAddResourceTests.cs => Creating/AtomicCreateResourceTests.cs} (93%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/{Add/AtomicAddResourceWithToOneRelationshipTests.cs => Creating/AtomicCreateResourceWithToOneRelationshipTests.cs} (94%) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs delete mode 100644 test/OperationsExampleTests/Remove/RemoveTests.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/AtomicOperationsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/AtomicOperationsController.cs index f30893a4c1..4b8958dd01 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/AtomicOperationsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/AtomicOperationsController.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCoreExample.Controllers { - [DisableRoutingConvention, Route("/operations")] + [DisableRoutingConvention, Route("/api/v1/operations")] public class AtomicOperationsController : JsonApiAtomicOperationsController { public AtomicOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, diff --git a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs index 0bad846524..9be1218a16 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs @@ -4,7 +4,6 @@ using System.Net; using System.Threading; using System.Threading.Tasks; -using JsonApiDotNetCore.AtomicOperations.Processors; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Add/AtomicAddResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs similarity index 93% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Add/AtomicAddResourceTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index 74df9a16cc..c19ee9ddfa 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Add/AtomicAddResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -11,15 +11,15 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Add +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Creating { - public sealed class AtomicAddResourceTests + public sealed class AtomicCreateResourceTests : IClassFixture, OperationsDbContext>> { private readonly IntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicAddResourceTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicCreateResourceTests(IntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; @@ -31,7 +31,7 @@ public AtomicAddResourceTests(IntegrationTestContext(route, requestBody); @@ -84,7 +84,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_add_resource_without_attributes_or_relationships() + public async Task Can_create_resource_without_attributes_or_relationships() { // Arrange var requestBody = new @@ -108,7 +108,7 @@ public async Task Can_add_resource_without_attributes_or_relationships() } }; - var route = "/operations"; + var route = "/api/v1/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -135,7 +135,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_add_resources() + public async Task Can_create_resources() { // Arrange const int elementCount = 5; @@ -167,7 +167,7 @@ public async Task Can_add_resources() atomic__operations = operationElements }; - var route = "/operations"; + var route = "/api/v1/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Add/AtomicAddResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs similarity index 94% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Add/AtomicAddResourceWithToOneRelationshipTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs index 76bd88e7da..369811559f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Add/AtomicAddResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs @@ -11,15 +11,15 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Add +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Creating { - public sealed class AtomicAddResourceWithToOneRelationshipTests + public sealed class AtomicCreateResourceWithToOneRelationshipTests : IClassFixture, OperationsDbContext>> { private readonly IntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicAddResourceWithToOneRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicCreateResourceWithToOneRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; @@ -31,7 +31,7 @@ public AtomicAddResourceWithToOneRelationshipTests(IntegrationTestContext } }; - var route = "/operations"; + var route = "/api/v1/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -103,7 +103,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_add_resources_with_ToOne_relationship() + public async Task Can_create_resources_with_ToOne_relationship() { // Arrange const int elementCount = 5; @@ -151,7 +151,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => atomic__operations = operationElements }; - var route = "/operations"; + var route = "/api/v1/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -193,7 +193,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_add_resource_with_ToOne_relationship_using_local_ID() + public async Task Can_create_resource_with_ToOne_relationship_using_local_ID() { // Arrange var newCompany = _fakers.RecordCompany.Generate(); @@ -245,7 +245,7 @@ public async Task Can_add_resource_with_ToOne_relationship_using_local_ID() } }; - var route = "/operations"; + var route = "/api/v1/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs new file mode 100644 index 0000000000..31d33ad367 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs @@ -0,0 +1,137 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Deleting +{ + public sealed class AtomicDeleteResourceTests + : IClassFixture, OperationsDbContext>> + { + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicDeleteResourceTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + }); + } + + [Fact] + public async Task Can_delete_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 = "/api/v1/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + responseDocument.Results[0].Data.Should().BeNull(); + + 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_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 = "/api/v1/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(elementCount); + + for (int index = 0; index < elementCount; index++) + { + responseDocument.Results[index].Data.Should().BeNull(); + } + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var tracksInDatabase = await dbContext.MusicTracks + .ToListAsync(); + + tracksInDatabase.Should().BeEmpty(); + }); + } + } +} diff --git a/test/OperationsExampleTests/Remove/RemoveTests.cs b/test/OperationsExampleTests/Remove/RemoveTests.cs deleted file mode 100644 index fa8d517ec2..0000000000 --- a/test/OperationsExampleTests/Remove/RemoveTests.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Serialization.Objects; -using OperationsExample; -using OperationsExampleTests.Factories; -using Xunit; - -namespace OperationsExampleTests.Remove -{ - [Collection("WebHostCollection")] - public class RemoveTests - { - private readonly TestFixture _fixture; - private readonly Faker _faker = new Faker(); - - public RemoveTests(TestFixture fixture) - { - _fixture = fixture; - } - - [Fact] - public async Task Can_Remove_Author() - { - // arrange - var author = AuthorFactory.Get(); - _fixture.Context.AuthorDifferentDbContextName.Add(author); - _fixture.Context.SaveChanges(); - - var content = new - { - atomic__operations = new[] - { - new Dictionary - { - {"op", "remove"}, - {"ref", new {type = "authors", id = author.StringId}} - } - } - }; - - // act - var (response, data) = await _fixture.PostAsync("api/v1/operations", content); - - // assert - Assert.NotNull(response); - Assert.NotNull(data); - _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - Assert.Single(data.Results); - Assert.Null(_fixture.Context.AuthorDifferentDbContextName.SingleOrDefault(a => a.Id == author.Id)); - } - - [Fact] - public async Task Can_Remove_Authors() - { - // arrange - var count = _faker.Random.Int(1, 10); - var authors = AuthorFactory.Get(count); - - _fixture.Context.AuthorDifferentDbContextName.AddRange(authors); - _fixture.Context.SaveChanges(); - - var content = new - { - atomic__operations = new List() - }; - - for (int i = 0; i < count; i++) - { - content.atomic__operations.Add( - new Dictionary - { - {"op", "remove"}, - {"ref", new {type = "authors", id = authors[i].StringId}} - } - ); - } - - // act - var (response, data) = await _fixture.PostAsync("api/v1/operations", content); - - // assert - Assert.NotNull(response); - Assert.NotNull(data); - _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - Assert.Equal(count, data.Results.Count); - - for (int i = 0; i < count; i++) - { - Assert.Null(data.Results[i].Data); - Assert.Null(_fixture.Context.AuthorDifferentDbContextName.SingleOrDefault(a => a.Id == authors[i].Id)); - } - } - } -} From de2c3d36a6c16d27b4271c053aaab6106e7572d1 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 26 Nov 2020 13:58:58 +0100 Subject: [PATCH 011/123] Converted atomic:operations Update tests --- .../Updating/AtomicUpdateResourceTests.cs | 224 ++++++++++++++++++ .../Updating/Resources/UpdateResourceTests.cs | 10 + .../Update/UpdateTests.cs | 133 ----------- 3 files changed, 234 insertions(+), 133 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/AtomicUpdateResourceTests.cs delete mode 100644 test/OperationsExampleTests/Update/UpdateTests.cs diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/AtomicUpdateResourceTests.cs new file mode 100644 index 0000000000..1959f6a5c1 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/AtomicUpdateResourceTests.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating +{ + public sealed class AtomicUpdateResourceTests + : IClassFixture, OperationsDbContext>> + { + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicUpdateResourceTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + }); + } + + [Fact] + public async Task Can_update_resource() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.OwnedBy = _fakers.RecordCompany.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 + } + } + } + } + }; + + var route = "/api/v1/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + responseDocument.Results[0].SingleData.Should().BeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var tracksInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.OwnedBy) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + tracksInDatabase.Title.Should().Be(newTitle); + tracksInDatabase.Genre.Should().Be(existingTrack.Genre); + + tracksInDatabase.OwnedBy.Should().NotBeNull(); + tracksInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); + }); + } + + [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 = "/api/v1/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + responseDocument.Results[0].SingleData.Should().BeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var tracksInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.OwnedBy) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + + tracksInDatabase.Title.Should().Be(existingTrack.Title); + tracksInDatabase.Genre.Should().Be(existingTrack.Genre); + + tracksInDatabase.OwnedBy.Should().NotBeNull(); + tracksInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); + }); + } + + [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 = "/api/v1/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(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().BeNull(); + } + + 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); + } + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs index 2305fa532a..8977a9d221 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs @@ -58,6 +58,16 @@ 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/OperationsExampleTests/Update/UpdateTests.cs b/test/OperationsExampleTests/Update/UpdateTests.cs deleted file mode 100644 index 95c762138f..0000000000 --- a/test/OperationsExampleTests/Update/UpdateTests.cs +++ /dev/null @@ -1,133 +0,0 @@ -using Bogus; -using OperationsExampleTests.Factories; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using JsonApiDotNetCore.Serialization.Objects; -using OperationsExample; -using Xunit; - -namespace OperationsExampleTests.Update -{ - [Collection("WebHostCollection")] - public class UpdateTests - { - private readonly TestFixture _fixture; - private readonly Faker _faker = new Faker(); - - public UpdateTests(TestFixture fixture) - { - _fixture = fixture; - } - - [Fact] - public async Task Can_Update_Author() - { - // arrange - var author = AuthorFactory.Get(); - var updates = AuthorFactory.Get(); - _fixture.Context.AuthorDifferentDbContextName.Add(author); - _fixture.Context.SaveChanges(); - - var content = new - { - atomic__operations = new[] - { - new Dictionary - { - {"op", "update"}, - { - "ref", new - { - type = "authors", - id = author.Id, - } - }, - { - "data", new - { - type = "authors", - id = author.Id, - attributes = new - { - firstName = updates.FirstName - } - } - }, - } - } - }; - - // act - var (response, data) = await _fixture.PostAsync("api/v1/operations", content); - - // assert - Assert.NotNull(response); - _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - Assert.NotNull(data); - Assert.Single(data.Results); - Assert.Null(data.Results[0].Data); - - Assert.Null(data.Results.Single().SingleData); - } - - [Fact] - public async Task Can_Update_Authors() - { - // arrange - var count = _faker.Random.Int(1, 10); - - var authors = AuthorFactory.Get(count); - var updates = AuthorFactory.Get(count); - - _fixture.Context.AuthorDifferentDbContextName.AddRange(authors); - _fixture.Context.SaveChanges(); - - var content = new - { - atomic__operations = new List() - }; - - for (int i = 0; i < count; i++) - { - content.atomic__operations.Add(new Dictionary - { - {"op", "update"}, - { - "ref", new - { - type = "authors", - id = authors[i].Id, - } - }, - { - "data", new - { - type = "authors", - id = authors[i].Id, - attributes = new - { - firstName = updates[i].FirstName - } - } - }, - }); - } - - // act - var (response, data) = await _fixture.PostAsync("api/v1/operations", content); - - // assert - Assert.NotNull(response); - _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - Assert.NotNull(data); - Assert.Equal(count, data.Results.Count); - - for (int i = 0; i < count; i++) - { - Assert.Null(data.Results[i].SingleData); - } - } - } -} From 07cb7929c08f4d402f4273896b1f29d13c88b78b Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 26 Nov 2020 15:47:02 +0100 Subject: [PATCH 012/123] Converted rollback test and removed separate projects --- JsonApiDotNetCore.sln | 30 ----- .../Controllers/AtomicOperationsController.cs | 19 --- .../OperationsExample.csproj | 10 -- src/Examples/OperationsExample/Program.cs | 19 --- .../Properties/launchSettings.json | 30 ----- src/Examples/OperationsExample/Startup.cs | 48 -------- src/Examples/OperationsExample/TestStartup.cs | 50 -------- .../OperationsExample/appsettings.json | 13 -- .../AtomicOperationsProcessor.cs | 22 +++- .../Properties/AssemblyInfo.cs | 1 - .../Mixed/MixedOperationsTests.cs | 113 ++++++++++++++++++ .../AtomicOperations/MusicTrack.cs | 1 + .../Factories/ArticleFactory.cs | 25 ---- .../Factories/AuthorFactory.cs | 25 ---- .../OperationsExampleTests.csproj | 22 ---- test/OperationsExampleTests/TestFixture.cs | 80 ------------- .../Transactions/TransactionFailureTests.cs | 95 --------------- .../WebHostCollection.cs | 10 -- test/OperationsExampleTests/xunit.runner.json | 4 - 19 files changed, 130 insertions(+), 487 deletions(-) delete mode 100644 src/Examples/OperationsExample/Controllers/AtomicOperationsController.cs delete mode 100644 src/Examples/OperationsExample/OperationsExample.csproj delete mode 100644 src/Examples/OperationsExample/Program.cs delete mode 100644 src/Examples/OperationsExample/Properties/launchSettings.json delete mode 100644 src/Examples/OperationsExample/Startup.cs delete mode 100644 src/Examples/OperationsExample/TestStartup.cs delete mode 100644 src/Examples/OperationsExample/appsettings.json create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs delete mode 100644 test/OperationsExampleTests/Factories/ArticleFactory.cs delete mode 100644 test/OperationsExampleTests/Factories/AuthorFactory.cs delete mode 100644 test/OperationsExampleTests/OperationsExampleTests.csproj delete mode 100644 test/OperationsExampleTests/TestFixture.cs delete mode 100644 test/OperationsExampleTests/Transactions/TransactionFailureTests.cs delete mode 100644 test/OperationsExampleTests/WebHostCollection.cs delete mode 100644 test/OperationsExampleTests/xunit.runner.json diff --git a/JsonApiDotNetCore.sln b/JsonApiDotNetCore.sln index 5c3021b916..0fc8aa3995 100644 --- a/JsonApiDotNetCore.sln +++ b/JsonApiDotNetCore.sln @@ -45,10 +45,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MultiDbContextExample", "sr EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MultiDbContextTests", "test\MultiDbContextTests\MultiDbContextTests.csproj", "{EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OperationsExample", "src\Examples\OperationsExample\OperationsExample.csproj", "{86861146-9D5A-44A9-86CD-6E102278B094}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OperationsExampleTests", "test\OperationsExampleTests\OperationsExampleTests.csproj", "{D60E0F29-3410-493B-9428-E174E8B8F42E}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -203,30 +199,6 @@ Global {EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA}.Release|x64.Build.0 = Release|Any CPU {EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA}.Release|x86.ActiveCfg = Release|Any CPU {EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA}.Release|x86.Build.0 = Release|Any CPU - {86861146-9D5A-44A9-86CD-6E102278B094}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {86861146-9D5A-44A9-86CD-6E102278B094}.Debug|Any CPU.Build.0 = Debug|Any CPU - {86861146-9D5A-44A9-86CD-6E102278B094}.Debug|x64.ActiveCfg = Debug|Any CPU - {86861146-9D5A-44A9-86CD-6E102278B094}.Debug|x64.Build.0 = Debug|Any CPU - {86861146-9D5A-44A9-86CD-6E102278B094}.Debug|x86.ActiveCfg = Debug|Any CPU - {86861146-9D5A-44A9-86CD-6E102278B094}.Debug|x86.Build.0 = Debug|Any CPU - {86861146-9D5A-44A9-86CD-6E102278B094}.Release|Any CPU.ActiveCfg = Release|Any CPU - {86861146-9D5A-44A9-86CD-6E102278B094}.Release|Any CPU.Build.0 = Release|Any CPU - {86861146-9D5A-44A9-86CD-6E102278B094}.Release|x64.ActiveCfg = Release|Any CPU - {86861146-9D5A-44A9-86CD-6E102278B094}.Release|x64.Build.0 = Release|Any CPU - {86861146-9D5A-44A9-86CD-6E102278B094}.Release|x86.ActiveCfg = Release|Any CPU - {86861146-9D5A-44A9-86CD-6E102278B094}.Release|x86.Build.0 = Release|Any CPU - {D60E0F29-3410-493B-9428-E174E8B8F42E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D60E0F29-3410-493B-9428-E174E8B8F42E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D60E0F29-3410-493B-9428-E174E8B8F42E}.Debug|x64.ActiveCfg = Debug|Any CPU - {D60E0F29-3410-493B-9428-E174E8B8F42E}.Debug|x64.Build.0 = Debug|Any CPU - {D60E0F29-3410-493B-9428-E174E8B8F42E}.Debug|x86.ActiveCfg = Debug|Any CPU - {D60E0F29-3410-493B-9428-E174E8B8F42E}.Debug|x86.Build.0 = Debug|Any CPU - {D60E0F29-3410-493B-9428-E174E8B8F42E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D60E0F29-3410-493B-9428-E174E8B8F42E}.Release|Any CPU.Build.0 = Release|Any CPU - {D60E0F29-3410-493B-9428-E174E8B8F42E}.Release|x64.ActiveCfg = Release|Any CPU - {D60E0F29-3410-493B-9428-E174E8B8F42E}.Release|x64.Build.0 = Release|Any CPU - {D60E0F29-3410-493B-9428-E174E8B8F42E}.Release|x86.ActiveCfg = Release|Any CPU - {D60E0F29-3410-493B-9428-E174E8B8F42E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -244,8 +216,6 @@ Global {21D27239-138D-4604-8E49-DCBE41BCE4C8} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF} {6CAFDDBE-00AB-4784-801B-AB419C3C3A26} = {026FBC6C-AF76-4568-9B87-EC73457899FD} {EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} - {86861146-9D5A-44A9-86CD-6E102278B094} = {026FBC6C-AF76-4568-9B87-EC73457899FD} - {D60E0F29-3410-493B-9428-E174E8B8F42E} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A2421882-8F0A-4905-928F-B550B192F9A4} diff --git a/src/Examples/OperationsExample/Controllers/AtomicOperationsController.cs b/src/Examples/OperationsExample/Controllers/AtomicOperationsController.cs deleted file mode 100644 index 0099f76db6..0000000000 --- a/src/Examples/OperationsExample/Controllers/AtomicOperationsController.cs +++ /dev/null @@ -1,19 +0,0 @@ -using JsonApiDotNetCore.AtomicOperations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Controllers.Annotations; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace OperationsExample.Controllers -{ - [DisableRoutingConvention, Route("api/v1/operations")] - public class AtomicOperationsController : JsonApiAtomicOperationsController - { - public AtomicOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IAtomicOperationsProcessor processor) - : base(options, loggerFactory, processor) - { - } - } -} diff --git a/src/Examples/OperationsExample/OperationsExample.csproj b/src/Examples/OperationsExample/OperationsExample.csproj deleted file mode 100644 index 129cc07142..0000000000 --- a/src/Examples/OperationsExample/OperationsExample.csproj +++ /dev/null @@ -1,10 +0,0 @@ - - - $(NetCoreAppVersion) - - - - - - - diff --git a/src/Examples/OperationsExample/Program.cs b/src/Examples/OperationsExample/Program.cs deleted file mode 100644 index a6f4d5844e..0000000000 --- a/src/Examples/OperationsExample/Program.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Hosting; - -namespace OperationsExample -{ - public class Program - { - public static void Main(string[] args) - { - CreateHostBuilder(args).Build().Run(); - } - - public static IHostBuilder CreateHostBuilder(string[] args) - { - return Host.CreateDefaultBuilder(args) - .ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup()); - } - } -} diff --git a/src/Examples/OperationsExample/Properties/launchSettings.json b/src/Examples/OperationsExample/Properties/launchSettings.json deleted file mode 100644 index 9eda7ff393..0000000000 --- a/src/Examples/OperationsExample/Properties/launchSettings.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "$schema": "http://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:14155", - "sslPort": 44355 - } - }, - "profiles": { - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": false, - "launchUrl": "/operations", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "Kestrel": { - "commandName": "Project", - "launchBrowser": false, - "launchUrl": "/operations", - "applicationUrl": "https://localhost:44355;http://localhost:14155", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/src/Examples/OperationsExample/Startup.cs b/src/Examples/OperationsExample/Startup.cs deleted file mode 100644 index 7681a6c35c..0000000000 --- a/src/Examples/OperationsExample/Startup.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCoreExample.Data; -using Microsoft.AspNetCore.Builder; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; - -namespace OperationsExample -{ - public class Startup - { - private readonly string _connectionString; - - public Startup(IConfiguration configuration) - { - string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - _connectionString = configuration["Data:DefaultConnection"].Replace("###", postgresPassword); - } - - // This method gets called by the runtime. Use this method to add services to the container. - public virtual void ConfigureServices(IServiceCollection services) - { - services.AddDbContext(options => - { - options.UseNpgsql(_connectionString, - postgresOptions => postgresOptions.SetPostgresVersion(new Version(9, 6))); - }); - - services.AddJsonApi(options => - { - options.IncludeExceptionStackTraceInErrors = true; - options.SerializerSettings.Formatting = Formatting.Indented; - }); - } - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, AppDbContext context) - { - context.Database.EnsureCreated(); - - app.UseRouting(); - app.UseJsonApi(); - app.UseEndpoints(endpoints => endpoints.MapControllers()); - } - } -} diff --git a/src/Examples/OperationsExample/TestStartup.cs b/src/Examples/OperationsExample/TestStartup.cs deleted file mode 100644 index fa4f589f52..0000000000 --- a/src/Examples/OperationsExample/TestStartup.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -namespace OperationsExample -{ - public class TestStartup : Startup - { - public TestStartup(IConfiguration configuration) - : base(configuration) - { - } - - public override void ConfigureServices(IServiceCollection services) - { - services.AddSingleton(); - - base.ConfigureServices(services); - } - - /// - /// Advances the clock one second each time the current time is requested. - /// - private class TickingSystemClock : ISystemClock - { - private DateTimeOffset _utcNow; - - public DateTimeOffset UtcNow - { - get - { - var utcNow = _utcNow; - _utcNow = _utcNow.AddSeconds(1); - return utcNow; - } - } - - public TickingSystemClock() - : this(new DateTimeOffset(new DateTime(2000, 1, 1))) - { - } - - public TickingSystemClock(DateTimeOffset utcNow) - { - _utcNow = utcNow; - } - } - } -} diff --git a/src/Examples/OperationsExample/appsettings.json b/src/Examples/OperationsExample/appsettings.json deleted file mode 100644 index 866a5a6b6f..0000000000 --- a/src/Examples/OperationsExample/appsettings.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "Data": { - "DefaultConnection": "Host=localhost;Port=5432;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=###" - }, - "Logging": { - "LogLevel": { - "Default": "Warning", - "Microsoft": "Warning", - "Microsoft.EntityFrameworkCore.Database.Command": "Information" - } - }, - "AllowedHosts": "*" -} diff --git a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs index 9be1218a16..4a4af0a6b7 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs @@ -65,15 +65,20 @@ public async Task> ProcessAsync(IList> ProcessAsync(IList, OperationsDbContext>> + { + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public MixedOperationsTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + }); + } + + [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 = "/api/v1/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(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/MusicTrack.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs index 016205de3e..dd8f4dfc1d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs @@ -25,6 +25,7 @@ public sealed class MusicTrack : Identifiable [HasOne] public RecordCompany OwnedBy { get; set; } + [HasMany] public IList Performers { get; set; } } } diff --git a/test/OperationsExampleTests/Factories/ArticleFactory.cs b/test/OperationsExampleTests/Factories/ArticleFactory.cs deleted file mode 100644 index b53ad8e972..0000000000 --- a/test/OperationsExampleTests/Factories/ArticleFactory.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Collections.Generic; -using Bogus; -using JsonApiDotNetCoreExample.Models; - -namespace OperationsExampleTests.Factories -{ - public static class ArticleFactory - { - public static Article Get() - { - var faker = new Faker
(); - faker.RuleFor(m => m.Caption, f => f.Lorem.Sentence()); - return faker.Generate(); - } - - public static List
Get(int count) - { - var articles = new List
(); - for (int i = 0; i < count; i++) - articles.Add(Get()); - - return articles; - } - } -} diff --git a/test/OperationsExampleTests/Factories/AuthorFactory.cs b/test/OperationsExampleTests/Factories/AuthorFactory.cs deleted file mode 100644 index f8b5ce91cf..0000000000 --- a/test/OperationsExampleTests/Factories/AuthorFactory.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Collections.Generic; -using Bogus; -using JsonApiDotNetCoreExample.Models; - -namespace OperationsExampleTests.Factories -{ - public static class AuthorFactory - { - public static Author Get() - { - var faker = new Faker(); - faker.RuleFor(m => m.FirstName, f => f.Person.FirstName); - return faker.Generate(); - } - - public static List Get(int count) - { - var authors = new List(); - for (int i = 0; i < count; i++) - authors.Add(Get()); - - return authors; - } - } -} diff --git a/test/OperationsExampleTests/OperationsExampleTests.csproj b/test/OperationsExampleTests/OperationsExampleTests.csproj deleted file mode 100644 index f4ba1d8314..0000000000 --- a/test/OperationsExampleTests/OperationsExampleTests.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - $(NetCoreAppVersion) - - - - - PreserveNewest - - - - - - - - - - - - - - diff --git a/test/OperationsExampleTests/TestFixture.cs b/test/OperationsExampleTests/TestFixture.cs deleted file mode 100644 index 1248651608..0000000000 --- a/test/OperationsExampleTests/TestFixture.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Repositories; -using JsonApiDotNetCoreExample.Data; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; -using Xunit; - -namespace OperationsExampleTests -{ - public class TestFixture : IDisposable - where TStartup : class - { - private readonly TestServer _server; - private readonly HttpClient _client; - private bool _isDisposed; - - public AppDbContext Context { get; } - - public TestFixture() - { - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - _server = new TestServer(builder); - - _client = _server.CreateClient(); - - var dbContextResolver = _server.Host.Services.GetRequiredService(); - Context = (AppDbContext) dbContextResolver.GetContext(); - } - - public void AssertEqualStatusCode(HttpStatusCode expected, HttpResponseMessage response) - { - var responseBody = response.Content.ReadAsStringAsync().Result; - Assert.True(expected == response.StatusCode, - $"Got {response.StatusCode} status code instead of {expected}. Response body: {responseBody}"); - } - - public void Dispose() - { - if (!_isDisposed) - { - _isDisposed = true; - - _client.Dispose(); - _server.Dispose(); - } - } - - public async Task<(HttpResponseMessage response, T data)> PostAsync(string route, object data) - { - var response = await PostAsync(route, data); - var json = await response.Content.ReadAsStringAsync(); - var obj = JsonConvert.DeserializeObject(json); - return (response, obj); - } - - private async Task PostAsync(string route, object data) - { - var request = new HttpRequestMessage(HttpMethod.Post, route); - - if (data != null) - { - var requestBody = JsonConvert.SerializeObject(data); - requestBody = requestBody.Replace("__", ":"); - - request.Content = new StringContent(requestBody); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - } - - return await _client.SendAsync(request); - } - } -} diff --git a/test/OperationsExampleTests/Transactions/TransactionFailureTests.cs b/test/OperationsExampleTests/Transactions/TransactionFailureTests.cs deleted file mode 100644 index c8668b5144..0000000000 --- a/test/OperationsExampleTests/Transactions/TransactionFailureTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System; -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.EntityFrameworkCore; -using OperationsExample; -using OperationsExampleTests.Factories; -using Xunit; - -namespace OperationsExampleTests.Transactions -{ - [Collection("WebHostCollection")] - public class TransactionFailureTests - { - private readonly TestFixture _fixture; - private readonly Faker _faker = new Faker(); - - public TransactionFailureTests(TestFixture fixture) - { - _fixture = fixture; - } - - [Fact] - public async Task Cannot_Create_Author_If_Article_Creation_Fails() - { - // arrange - var author = AuthorFactory.Get(); - var article = ArticleFactory.Get(); - - // do this so that the name is random enough for db validations - author.FirstName = Guid.NewGuid().ToString("N"); - article.Caption = Guid.NewGuid().ToString("N"); - - var content = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "authors", - attributes = new - { - firstName = author.FirstName - } - } - }, - new - { - op = "add", - data = new - { - type = "articles", - attributes = new - { - caption = article.Caption - }, - relationships = new - { - author = new - { - data = new - { - type = "authors", - id = 99999999 - } - } - } - } - } - } - }; - - // act - var (response, data) = await _fixture.PostAsync("api/v1/operations", content); - - // assert - Assert.NotNull(response); - // for now, it is up to application implementations to perform validation and - // provide the proper HTTP response code - _fixture.AssertEqualStatusCode(HttpStatusCode.InternalServerError, response); - Assert.Single(data.Errors); - Assert.Contains("operation[1] (Add)", data.Errors[0].Detail); - - var dbAuthors = await _fixture.Context.AuthorDifferentDbContextName.Where(a => a.FirstName == author.FirstName).ToListAsync(); - var dbArticles = await _fixture.Context.Articles.Where(a => a.Caption == article.Caption).ToListAsync(); - Assert.Empty(dbAuthors); - Assert.Empty(dbArticles); - } - } -} diff --git a/test/OperationsExampleTests/WebHostCollection.cs b/test/OperationsExampleTests/WebHostCollection.cs deleted file mode 100644 index b7230d54da..0000000000 --- a/test/OperationsExampleTests/WebHostCollection.cs +++ /dev/null @@ -1,10 +0,0 @@ -using OperationsExample; -using Xunit; - -namespace OperationsExampleTests -{ - [CollectionDefinition("WebHostCollection")] - public class WebHostCollection : ICollectionFixture> - { - } -} diff --git a/test/OperationsExampleTests/xunit.runner.json b/test/OperationsExampleTests/xunit.runner.json deleted file mode 100644 index 9db029ba52..0000000000 --- a/test/OperationsExampleTests/xunit.runner.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "parallelizeAssembly": false, - "parallelizeTestCollections": false -} From f191bc051f19191c31c653388b42e021ae0d60fc Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 26 Nov 2020 16:11:11 +0100 Subject: [PATCH 013/123] Rename plus overridable ToString --- .../AtomicOperationsProcessor.cs | 2 +- .../Objects/AtomicOperationObject.cs | 2 +- .../Serialization/Objects/AtomicReference.cs | 17 ++++++++++ .../Objects/AtomicResourceReference.cs | 10 ------ .../Objects/ResourceIdentifierObject.cs | 31 ++++++++++++++++++- 5 files changed, 49 insertions(+), 13 deletions(-) create mode 100644 src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs delete mode 100644 src/JsonApiDotNetCore/Serialization/Objects/AtomicResourceReference.cs diff --git a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs index 4a4af0a6b7..29304fbe66 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs @@ -159,7 +159,7 @@ private void ReplaceLocalIdsInResourceObject(ResourceObject resourceObject, List } } - private void ReplaceLocalIdsInRef(AtomicResourceReference reference, List results) + private void ReplaceLocalIdsInRef(AtomicReference reference, List results) { if (reference == null) return; if (HasLocalId(reference)) diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs index 8fb1c66295..45dfede0c1 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs @@ -17,7 +17,7 @@ public sealed class AtomicOperationObject : ExposableData public AtomicOperationCode Code { get; set; } [JsonProperty("ref", NullValueHandling = NullValueHandling.Ignore)] - public AtomicResourceReference Ref { get; set; } + public AtomicReference Ref { get; set; } [JsonProperty("href", NullValueHandling = NullValueHandling.Ignore)] public string Href { get; set; } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs new file mode 100644 index 0000000000..0133f409f6 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs @@ -0,0 +1,17 @@ +using System.Text; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.Serialization.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/AtomicResourceReference.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicResourceReference.cs deleted file mode 100644 index 3acb367a14..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicResourceReference.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.Serialization.Objects -{ - public sealed class AtomicResourceReference : ResourceIdentifierObject - { - [JsonProperty("relationship", NullValueHandling = NullValueHandling.Ignore)] - public string Relationship { get; set; } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs index e82e8d43e5..4ef281e8a6 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs @@ -1,3 +1,4 @@ +using System.Text; using Newtonsoft.Json; namespace JsonApiDotNetCore.Serialization.Objects @@ -15,7 +16,35 @@ public class ResourceIdentifierObject public override string ToString() { - return $"(type: {Type}, id: {Id}, lid: {LocalId})"; + 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", LocalId); + } + + 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("\""); + } } } } From 8fcedb60e6ef9b61c6129692b41edf8019f6369c Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 26 Nov 2020 18:21:16 +0100 Subject: [PATCH 014/123] Added local ID tracking --- .../AtomicOperationsProcessor.cs | 131 ++++++------- .../AtomicOperations/ILocalIdTracker.cs | 23 +++ .../AtomicOperations/LocalIdTracker.cs | 39 ++++ .../Processors/CreateOperationProcessor.cs | 57 ++++-- .../Processors/UpdateOperationProcessor.cs | 12 +- .../JsonApiApplicationBuilder.cs | 1 + .../Objects/ResourceIdentifierObject.cs | 4 +- .../ResponseSerializerFactory.cs | 17 ++ ...reateResourceWithToOneRelationshipTests.cs | 173 +++++++++++++++++- 9 files changed, 363 insertions(+), 94 deletions(-) create mode 100644 src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs create mode 100644 src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs diff --git a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs index 29304fbe66..730b9b080a 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs @@ -9,6 +9,7 @@ using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; @@ -18,21 +19,23 @@ namespace JsonApiDotNetCore.AtomicOperations public class AtomicOperationsProcessor : IAtomicOperationsProcessor { private readonly IAtomicOperationProcessorResolver _resolver; + private readonly ILocalIdTracker _localIdTracker; private readonly DbContext _dbContext; private readonly IJsonApiRequest _request; private readonly ITargetedFields _targetedFields; - private readonly IResourceGraph _resourceGraph; + private readonly IResourceContextProvider _resourceContextProvider; - public AtomicOperationsProcessor(IAtomicOperationProcessorResolver resolver, IJsonApiRequest request, - ITargetedFields targetedFields, IResourceGraph resourceGraph, + public AtomicOperationsProcessor(IAtomicOperationProcessorResolver resolver, ILocalIdTracker localIdTracker, + IJsonApiRequest request, ITargetedFields targetedFields, IResourceContextProvider resourceContextProvider, IEnumerable dbContextResolvers) { if (dbContextResolvers == null) throw new ArgumentNullException(nameof(dbContextResolvers)); _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); + _localIdTracker = localIdTracker ?? throw new ArgumentNullException(nameof(localIdTracker)); _request = request ?? throw new ArgumentNullException(nameof(request)); _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); - _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); var resolvers = dbContextResolvers.ToArray(); if (resolvers.Length != 1) @@ -49,17 +52,14 @@ public async Task> ProcessAsync(IList(); - var operationIndex = 0; - AtomicOperationCode? lastAttemptedOperation = null; // used for error messages only await using var transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken); try { foreach (var operation in operations) { - lastAttemptedOperation = operation.Code; - await ProcessOperation(operation, results, cancellationToken); - operationIndex++; + var result = await ProcessOperation(operation, cancellationToken); + results.Add(result); } await transaction.CommitAsync(cancellationToken); @@ -75,7 +75,7 @@ public async Task> ProcessAsync(IList> ProcessAsync(IList results, CancellationToken cancellationToken) + private async Task ProcessOperation(AtomicOperationObject operation, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - ReplaceLocalIdsInResourceObject(operation.SingleData, results); - ReplaceLocalIdsInRef(operation.Ref, results); + ReplaceLocalIdsInResourceObject(operation.SingleData); + ReplaceLocalIdInResourceIdentifierObject(operation.Ref); - string type = null; + string resourceName = null; if (operation.Code == AtomicOperationCode.Add || operation.Code == AtomicOperationCode.Update) { - type = operation.SingleData.Type; + resourceName = operation.SingleData?.Type; + if (resourceName == null) + { + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "The data.type element is required." + }); + } } - else if (operation.Code == AtomicOperationCode.Remove) + + if (operation.Code == AtomicOperationCode.Remove) { - type = operation.Ref.Type; + resourceName = operation.Ref?.Type; + if (resourceName == null) + { + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "The ref.type element is required." + }); + } + } + + var resourceContext = _resourceContextProvider.GetResourceContext(resourceName); + if (resourceContext == null) + { + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "Request body includes unknown resource type.", + Detail = $"Resource type '{resourceName}' does not exist." + }); } - ((JsonApiRequest)_request).PrimaryResource = _resourceGraph.GetResourceContext(type); - + ((JsonApiRequest)_request).PrimaryResource = resourceContext; + _targetedFields.Attributes.Clear(); _targetedFields.Relationships.Clear(); var processor = _resolver.ResolveProcessor(operation); - var result = await processor.ProcessAsync(operation, cancellationToken); - results.Add(result); + return await processor.ProcessAsync(operation, cancellationToken); } - private void ReplaceLocalIdsInResourceObject(ResourceObject resourceObject, List results) + private void ReplaceLocalIdsInResourceObject(ResourceObject resourceObject) { - if (resourceObject == null) - return; - - // it is strange to me that a top level resource object might use a lid. - // by not replacing it, we avoid a case where the first operation is an 'add' with an 'lid' - // and we would be unable to locate the matching 'lid' in 'results' - // - // we also create a scenario where I might try to update a resource I just created - // in this case, the 'data.id' will be null, but the 'ref.id' will be replaced by the correct 'id' from 'results' - // - // if(HasLocalId(resourceObject)) - // resourceObject.Id = GetIdFromLocalId(results, resourceObject.LocalId); - - if (resourceObject.Relationships != null) + if (resourceObject?.Relationships != null) { - foreach (var relationshipDictionary in resourceObject.Relationships) + foreach (var relationshipEntry in resourceObject.Relationships.Values) { - if (relationshipDictionary.Value.IsManyData) + if (relationshipEntry.IsManyData) { - foreach (var relationship in relationshipDictionary.Value.ManyData) - if (HasLocalId(relationship)) - relationship.Id = GetIdFromLocalId(results, relationship.LocalId); + foreach (var relationship in relationshipEntry.ManyData) + { + ReplaceLocalIdInResourceIdentifierObject(relationship); + } } else { - var relationship = relationshipDictionary.Value.SingleData; - if (HasLocalId(relationship)) - relationship.Id = GetIdFromLocalId(results, relationship.LocalId); + var relationship = relationshipEntry.SingleData; + + ReplaceLocalIdInResourceIdentifierObject(relationship); } } } } - private void ReplaceLocalIdsInRef(AtomicReference reference, List results) - { - if (reference == null) return; - if (HasLocalId(reference)) - reference.Id = GetIdFromLocalId(results, reference.LocalId); - } - - private bool HasLocalId(ResourceIdentifierObject rio) => string.IsNullOrEmpty(rio.LocalId) == false; - - private string GetIdFromLocalId(List results, string localId) + private void ReplaceLocalIdInResourceIdentifierObject(ResourceIdentifierObject resourceIdentifierObject) { - var referencedOp = results.FirstOrDefault(o => o.SingleData.LocalId == localId); - if (referencedOp == null) + if (resourceIdentifierObject?.Lid != null) { - throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + if (!_localIdTracker.IsAssigned(resourceIdentifierObject.Lid)) { - Title = "Could not locate lid in document.", - Detail = $"Could not locate lid '{localId}' in document." - }); - } + 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 '{resourceIdentifierObject.Lid}' is not available at this point." + }); + } - return referencedOp.SingleData.Id; + resourceIdentifierObject.Id = _localIdTracker.GetAssignedValue(resourceIdentifierObject.Lid); + } } } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs new file mode 100644 index 0000000000..c43ab00628 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs @@ -0,0 +1,23 @@ +namespace JsonApiDotNetCore.AtomicOperations +{ + /// + /// Used to track assignments and references to local IDs an in atomic:operations request. + /// + public interface ILocalIdTracker + { + /// + /// Assigns a server-generated value to a local ID. + /// + void AssignValue(string lid, string id); + + /// + /// Gets the server-assigned ID for the specified local ID. + /// + string GetAssignedValue(string lid); + + /// + /// Indicates whether a server-generated value is available for the specified local ID. + /// + bool IsAssigned(string lid); + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs new file mode 100644 index 0000000000..6a353f001c --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; + +namespace JsonApiDotNetCore.AtomicOperations +{ + /// + public sealed class LocalIdTracker : ILocalIdTracker + { + private readonly IDictionary _idsTracked = new Dictionary(); + + /// + public void AssignValue(string lid, string id) + { + if (IsAssigned(lid)) + { + throw new InvalidOperationException($"Cannot reassign to existing local ID '{lid}'."); + } + + _idsTracked[lid] = id; + } + + /// + public string GetAssignedValue(string lid) + { + if (!IsAssigned(lid)) + { + throw new InvalidOperationException($"Use of unassigned local ID '{lid}'."); + } + + return _idsTracked[lid]; + } + + /// + public bool IsAssigned(string lid) + { + return _idsTracked.ContainsKey(lid); + } + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateOperationProcessor.cs index da57a574b5..ea18c3716c 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateOperationProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateOperationProcessor.cs @@ -1,7 +1,9 @@ using System; +using System.Net; using System.Threading; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Building; @@ -15,36 +17,55 @@ public class CreateOperationProcessor : ICreateOperationProcesso where TResource : class, IIdentifiable { private readonly ICreateService _service; + private readonly ILocalIdTracker _localIdTracker; private readonly IJsonApiDeserializer _deserializer; private readonly IResourceObjectBuilder _resourceObjectBuilder; - private readonly IResourceGraph _resourceGraph; + private readonly IResourceContextProvider _resourceContextProvider; - public CreateOperationProcessor(ICreateService service, IJsonApiDeserializer deserializer, - IResourceObjectBuilder resourceObjectBuilder, IResourceGraph resourceGraph) + public CreateOperationProcessor(ICreateService service, ILocalIdTracker localIdTracker, IJsonApiDeserializer deserializer, + IResourceObjectBuilder resourceObjectBuilder, IResourceContextProvider resourceContextProvider) { _service = service ?? throw new ArgumentNullException(nameof(service)); + _localIdTracker = localIdTracker ?? throw new ArgumentNullException(nameof(localIdTracker)); _deserializer = deserializer ?? throw new ArgumentNullException(nameof(deserializer)); _resourceObjectBuilder = resourceObjectBuilder ?? throw new ArgumentNullException(nameof(resourceObjectBuilder)); - _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); } - public async Task ProcessAsync(AtomicOperationObject operation, CancellationToken cancellationToken) + public async Task ProcessAsync(AtomicOperationObject operation, + CancellationToken cancellationToken) { var model = (TResource) _deserializer.CreateResourceFromObject(operation.SingleData); var newResource = await _service.CreateAsync(model, cancellationToken); - ResourceContext resourceContext = _resourceGraph.GetResourceContext(operation.SingleData.Type); + if (operation.SingleData.Lid != null) + { + if (_localIdTracker.IsAssigned(operation.SingleData.Lid)) + { + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "Another local ID with the same name is already in use at this point.", + Detail = $"Another local ID with name '{operation.SingleData.Lid}' is already in use at this point." + }); + } + + var serverId = newResource == null ? operation.SingleData.Id : newResource.StringId; + _localIdTracker.AssignValue(operation.SingleData.Lid, serverId); + } - var operationResult = new AtomicResultObject + if (newResource != null) { - Data = _resourceObjectBuilder.Build(newResource, resourceContext.Attributes, resourceContext.Relationships) - }; + ResourceContext resourceContext = + _resourceContextProvider.GetResourceContext(operation.SingleData.Type); - // we need to persist the original request localId so that subsequent operations - // can locate the result of this operation by its localId - operationResult.SingleData.LocalId = operation.SingleData.LocalId; + return new AtomicResultObject + { + Data = _resourceObjectBuilder.Build(newResource, resourceContext.Attributes, + resourceContext.Relationships) + }; + } - return operationResult; + return new AtomicResultObject(); } } @@ -52,12 +73,14 @@ public async Task ProcessAsync(AtomicOperationObject operati /// Processes a single operation with code in a list of atomic operations. /// /// The resource type. - public class CreateOperationProcessor : CreateOperationProcessor, ICreateOperationProcessor + public class CreateOperationProcessor : CreateOperationProcessor, + ICreateOperationProcessor where TResource : class, IIdentifiable { - public CreateOperationProcessor(ICreateService service, IJsonApiDeserializer deserializer, - IResourceObjectBuilder resourceObjectBuilder, IResourceGraph resourceGraph) - : base(service, deserializer, resourceObjectBuilder, resourceGraph) + public CreateOperationProcessor(ICreateService service, ILocalIdTracker localIdTracker, + IJsonApiDeserializer deserializer, IResourceObjectBuilder resourceObjectBuilder, + IResourceContextProvider resourceContextProvider) + : base(service, localIdTracker, deserializer, resourceObjectBuilder, resourceContextProvider) { } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateOperationProcessor.cs index f3bf64cb4f..4040f8e704 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateOperationProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateOperationProcessor.cs @@ -19,15 +19,15 @@ public class UpdateOperationProcessor : IUpdateOperationProcesso private readonly IUpdateService _service; private readonly IJsonApiDeserializer _deserializer; private readonly IResourceObjectBuilder _resourceObjectBuilder; - private readonly IResourceGraph _resourceGraph; + private readonly IResourceContextProvider _resourceContextProvider; public UpdateOperationProcessor(IUpdateService service, IJsonApiDeserializer deserializer, - IResourceObjectBuilder resourceObjectBuilder, IResourceGraph resourceGraph) + IResourceObjectBuilder resourceObjectBuilder, IResourceContextProvider resourceContextProvider) { _service = service ?? throw new ArgumentNullException(nameof(service)); _deserializer = deserializer ?? throw new ArgumentNullException(nameof(deserializer)); _resourceObjectBuilder = resourceObjectBuilder ?? throw new ArgumentNullException(nameof(resourceObjectBuilder)); - _resourceGraph = resourceGraph ?? throw new ArgumentNullException(nameof(resourceGraph)); + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); } public async Task ProcessAsync(AtomicOperationObject operation, CancellationToken cancellationToken) @@ -48,7 +48,7 @@ public async Task ProcessAsync(AtomicOperationObject operati if (result != null) { - ResourceContext resourceContext = _resourceGraph.GetResourceContext(operation.SingleData.Type); + ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(operation.SingleData.Type); data = _resourceObjectBuilder.Build(result, resourceContext.Attributes, resourceContext.Relationships); } @@ -67,8 +67,8 @@ public class UpdateOperationProcessor : UpdateOperationProcessor { public UpdateOperationProcessor(IUpdateService service, IJsonApiDeserializer deserializer, - IResourceObjectBuilder resourceObjectBuilder, IResourceGraph resourceGraph) - : base(service, deserializer, resourceObjectBuilder, resourceGraph) + IResourceObjectBuilder resourceObjectBuilder, IResourceContextProvider resourceContextProvider) + : base(service, deserializer, resourceObjectBuilder, resourceContextProvider) { } } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 44b4ea899f..4f22ad4035 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -274,6 +274,7 @@ private void AddAtomicOperationsLayer() { _services.AddScoped(); _services.AddScoped(); + _services.AddScoped(); _services.AddScoped(typeof(ICreateOperationProcessor<>), typeof(CreateOperationProcessor<>)); _services.AddScoped(typeof(ICreateOperationProcessor<,>), typeof(CreateOperationProcessor<,>)); diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs index 4ef281e8a6..b216011bf7 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs @@ -12,7 +12,7 @@ public class ResourceIdentifierObject public string Id { get; set; } [JsonProperty("lid", NullValueHandling = NullValueHandling.Ignore, Order = -2)] - public string LocalId { get; set; } + public string Lid { get; set; } public override string ToString() { @@ -28,7 +28,7 @@ protected virtual void WriteMembers(StringBuilder builder) { WriteMember(builder, "type", Type); WriteMember(builder, "id", Id); - WriteMember(builder, "lid", LocalId); + WriteMember(builder, "lid", Lid); } protected static void WriteMember(StringBuilder builder, string memberName, string memberValue) diff --git a/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs b/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs index 6f995d061d..2d0034dc16 100644 --- a/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs +++ b/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs @@ -1,7 +1,9 @@ using System; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; namespace JsonApiDotNetCore.Serialization { @@ -25,6 +27,11 @@ public ResponseSerializerFactory(IJsonApiRequest request, IRequestScopedServiceP /// public IJsonApiSerializer GetSerializer() { + if (_request.Kind == EndpointKind.AtomicOperations && _request.PrimaryResource == null) + { + return new SimpleJsonApiSerializer(); + } + var targetType = GetDocumentType(); var serializerType = typeof(ResponseSerializer<>).MakeGenericType(targetType); @@ -39,4 +46,14 @@ private Type GetDocumentType() return resourceContext.ResourceType; } } + + public sealed class SimpleJsonApiSerializer : IJsonApiSerializer + { + // TODO: @OPS Choose something better to use, in case of error (before _request.PrimaryResource has been assigned). + // This is to workaround a crash when trying to resolve serializer after unhandled exception (hiding the original problem). + public string Serialize(object content) + { + return JsonConvert.SerializeObject(content); + } + } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs index 369811559f..4704cf0c65 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs @@ -197,7 +197,7 @@ public async Task Can_create_resource_with_ToOne_relationship_using_local_ID() { // Arrange var newCompany = _fakers.RecordCompany.Generate(); - var newTrack = _fakers.MusicTrack.Generate(); + var newTrackTitle = _fakers.MusicTrack.Generate().Title; const string companyLocalId = "company-1"; @@ -227,7 +227,7 @@ public async Task Can_create_resource_with_ToOne_relationship_using_local_ID() type = "musicTracks", attributes = new { - title = newTrack.Title + title = newTrackTitle }, relationships = new { @@ -257,14 +257,14 @@ public async Task Can_create_resource_with_ToOne_relationship_using_local_ID() responseDocument.Results[0].SingleData.Should().NotBeNull(); responseDocument.Results[0].SingleData.Type.Should().Be("recordCompanies"); - // TODO: @OPS: responseDocument.Results[0].SingleData.LocalId.Should().BeNull(); + 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.LocalId.Should().BeNull(); - responseDocument.Results[1].SingleData.Attributes["title"].Should().Be(newTrack.Title); + 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); @@ -275,7 +275,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .Include(musicTrack => musicTrack.OwnedBy) .FirstAsync(musicTrack => musicTrack.Id == newTrackId); - trackInDatabase.Title.Should().Be(newTrack.Title); + trackInDatabase.Title.Should().Be(newTrackTitle); trackInDatabase.OwnedBy.Should().NotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); @@ -283,5 +283,166 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.OwnedBy.CountryOfResidence.Should().Be(newCompany.CountryOfResidence); }); } + + [Fact] + public async Task Cannot_consume_unassigned_local_ID() + { + // TODO: @OPS: This can occur at multiple places: in a 'ref', in a to-one relationships, in an element of a to-many relationship etc... + + // 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 + }, + relationships = new + { + ownedBy = new + { + data = new + { + type = "recordCompanies", + lid = "company-1" + } + } + } + } + } + } + }; + + var route = "/api/v1/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(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 'company-1' is not available at this point."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_consume_local_ID_that_is_assigned_in_same_operation() + { + // 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 + }, + relationships = new + { + ownedBy = new + { + data = new + { + type = "recordCompanies", + lid = trackLocalId + } + } + } + } + } + } + }; + + var route = "/api/v1/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(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 'track-1' is not available at this point."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [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 = "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 = "/api/v1/operations"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(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 in use at this point."); + responseDocument.Errors[0].Detail.Should().Be("Another local ID with name 'playlist-1' is already in use at this point."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[1]"); + } } } From b4be29c2f0bad0c61800efc741860e346e682c50 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 27 Nov 2020 18:04:05 +0100 Subject: [PATCH 015/123] Added support for atomic:operations extension in ContentType header --- .../AtomicOperationsProcessor.cs | 1 - .../JsonApiApplicationBuilder.cs | 1 + .../Middleware/HeaderConstants.cs | 1 + .../Middleware/JsonApiMiddleware.cs | 13 +- .../AtomicOperationsResponseSerializer.cs | 49 ++++++ .../Serialization/IJsonApiSerializer.cs | 5 + .../Serialization/JsonApiWriter.cs | 2 +- .../Serialization/ResponseSerializer.cs | 14 +- .../ResponseSerializerFactory.cs | 16 +- .../IntegrationTestContext.cs | 10 +- .../Creating/AtomicCreateResourceTests.cs | 6 +- ...reateResourceWithToOneRelationshipTests.cs | 12 +- .../Deleting/AtomicDeleteResourceTests.cs | 4 +- .../Mixed/MixedOperationsTests.cs | 2 +- .../Updating/AtomicUpdateResourceTests.cs | 7 +- .../ContentTypeHeaderTests.cs | 144 ++++++++++++++++++ 16 files changed, 240 insertions(+), 47 deletions(-) create mode 100644 src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs diff --git a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs index 730b9b080a..4be644f8dc 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs @@ -9,7 +9,6 @@ using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 4f22ad4035..fcd6189c37 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -265,6 +265,7 @@ private void AddSerializationLayer() _services.AddScoped(); _services.AddScoped(); _services.AddScoped(); + _services.AddScoped(typeof(AtomicOperationsResponseSerializer)); _services.AddScoped(typeof(ResponseSerializer<>)); _services.AddScoped(sp => sp.GetRequiredService().GetSerializer()); _services.AddScoped(); 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/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index ef1bb3e978..0b7b1f6ebd 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -49,7 +49,7 @@ public async Task Invoke(HttpContext httpContext, var primaryResourceContext = CreatePrimaryResourceContext(routeValues, controllerResourceMapping, resourceContextProvider); if (primaryResourceContext != null) { - if (!await ValidateContentTypeHeaderAsync(httpContext, options.SerializerSettings) || + if (!await ValidateContentTypeHeaderAsync(HeaderConstants.MediaType, httpContext, options.SerializerSettings) || !await ValidateAcceptHeaderAsync(httpContext, options.SerializerSettings)) { return; @@ -61,6 +61,11 @@ public async Task Invoke(HttpContext httpContext, } else if (IsAtomicOperationsRequest(routeValues)) { + if (!await ValidateContentTypeHeaderAsync(HeaderConstants.AtomicOperationsMediaType, httpContext, options.SerializerSettings)) + { + return; + } + SetupAtomicOperationsRequest((JsonApiRequest)request); httpContext.RegisterJsonApiRequest(); @@ -85,15 +90,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; } diff --git a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs new file mode 100644 index 0000000000..2e54de15af --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs @@ -0,0 +1,49 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Building; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization +{ + /// + /// Server serializer implementation of for atomic:operations requests. + /// + public sealed class AtomicOperationsResponseSerializer : BaseSerializer, IJsonApiSerializer + { + private readonly IJsonApiOptions _options; + + public string ContentType { get; } = HeaderConstants.AtomicOperationsMediaType; + + public AtomicOperationsResponseSerializer(IResourceObjectBuilder resourceObjectBuilder, IJsonApiOptions options) + : base(resourceObjectBuilder) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + public string Serialize(object content) + { + if (content is AtomicOperationsDocument atomicOperationsDocument) + { + return SerializeOperationsDocument(atomicOperationsDocument); + } + + if (content is ErrorDocument errorDocument) + { + return SerializeErrorDocument(errorDocument); + } + + throw new InvalidOperationException("Data being returned must be errors or an atomic:operations document."); + } + + private string SerializeOperationsDocument(AtomicOperationsDocument content) + { + return SerializeObject(content, _options.SerializerSettings); + } + + private string SerializeErrorDocument(ErrorDocument errorDocument) + { + return SerializeObject(errorDocument, _options.SerializerSettings, serializer => { serializer.ApplyErrorSettings(); }); + } + } +} 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/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/ResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs index 89c59a0105..017e829055 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,8 @@ 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, @@ -66,19 +68,9 @@ public string Serialize(object data) return SerializeErrorDocument(errorDocument); } - if (data is AtomicOperationsDocument operationsDocument) - { - return SerializeOperationsDocument(operationsDocument); - } - throw new InvalidOperationException("Data being returned must be errors or resources."); } - private string SerializeOperationsDocument(AtomicOperationsDocument atomicOperationsDocument) - { - return SerializeObject(atomicOperationsDocument, _options.SerializerSettings); - } - private string SerializeErrorDocument(ErrorDocument errorDocument) { return SerializeObject(errorDocument, _options.SerializerSettings, serializer => { serializer.ApplyErrorSettings(); }); diff --git a/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs b/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs index 2d0034dc16..61eeba76e6 100644 --- a/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs +++ b/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs @@ -1,7 +1,7 @@ using System; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Serialization.Building; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; @@ -27,9 +27,9 @@ public ResponseSerializerFactory(IJsonApiRequest request, IRequestScopedServiceP /// public IJsonApiSerializer GetSerializer() { - if (_request.Kind == EndpointKind.AtomicOperations && _request.PrimaryResource == null) + if (_request.Kind == EndpointKind.AtomicOperations) { - return new SimpleJsonApiSerializer(); + return (IJsonApiSerializer)_provider.GetRequiredService(typeof(AtomicOperationsResponseSerializer)); } var targetType = GetDocumentType(); @@ -46,14 +46,4 @@ private Type GetDocumentType() return resourceContext.ResourceType; } } - - public sealed class SimpleJsonApiSerializer : IJsonApiSerializer - { - // TODO: @OPS Choose something better to use, in case of error (before _request.PrimaryResource has been assigned). - // This is to workaround a crash when trying to resolve serializer after unhandled exception (hiding the original problem). - public string Serialize(object content) - { - return JsonConvert.SerializeObject(content); - } - } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs index 4982bdf302..764b19d716 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs @@ -118,6 +118,15 @@ public async Task RunOnDatabaseAsync(Func asyncAction) 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, @@ -146,7 +155,6 @@ public async Task RunOnDatabaseAsync(Func asyncAction) if (!string.IsNullOrEmpty(requestText)) { requestText = requestText.Replace("atomic__", "atomic:"); - request.Content = new StringContent(requestText); if (contentType != null) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index c19ee9ddfa..8e39071562 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -60,7 +60,7 @@ public async Task Can_create_resource() var route = "/api/v1/operations"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); @@ -111,7 +111,7 @@ public async Task Can_create_resource_without_attributes_or_relationships() var route = "/api/v1/operations"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); @@ -170,7 +170,7 @@ public async Task Can_create_resources() var route = "/api/v1/operations"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs index 4704cf0c65..783ee79ef6 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs @@ -77,7 +77,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/api/v1/operations"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); @@ -154,7 +154,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/api/v1/operations"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); @@ -248,7 +248,7 @@ public async Task Can_create_resource_with_ToOne_relationship_using_local_ID() var route = "/api/v1/operations"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); @@ -325,7 +325,7 @@ public async Task Cannot_consume_unassigned_local_ID() var route = "/api/v1/operations"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); @@ -378,7 +378,7 @@ public async Task Cannot_consume_local_ID_that_is_assigned_in_same_operation() var route = "/api/v1/operations"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); @@ -433,7 +433,7 @@ public async Task Cannot_reassign_local_ID() var route = "/api/v1/operations"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs index 31d33ad367..adf0cbc3d2 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs @@ -59,7 +59,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/api/v1/operations"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); @@ -113,7 +113,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/api/v1/operations"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs index 01f8e210de..592437fb31 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs @@ -89,7 +89,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/api/v1/operations"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/AtomicUpdateResourceTests.cs index 1959f6a5c1..7b008036ea 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/AtomicUpdateResourceTests.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -68,7 +67,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/api/v1/operations"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); @@ -128,7 +127,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/api/v1/operations"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); @@ -192,7 +191,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/api/v1/operations"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs index 2cce83a8fe..a15b9e060a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs @@ -3,6 +3,9 @@ using FluentAssertions; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.Extensions.DependencyInjection; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ContentNegotiation @@ -15,6 +18,12 @@ public sealed class ContentTypeHeaderTests public ContentTypeHeaderTests(IntegrationTestContext, PolicyDbContext> testContext) { _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + }); } [Fact] @@ -31,6 +40,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 = "/api/v1/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() { @@ -88,6 +130,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 = "/api/v1/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() { @@ -150,6 +225,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() { @@ -211,5 +317,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 = "/api/v1/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."); + } } } From 9b6e6b8d77095086fc4a3f06b9f7114094a15ca8 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 27 Nov 2020 19:51:19 +0100 Subject: [PATCH 016/123] Added support for atomic:operations extension in Accept headers --- .../Middleware/JsonApiMiddleware.cs | 12 +- .../ContentNegotiation/AcceptHeaderTests.cs | 132 +++++++++++++++++- 2 files changed, 138 insertions(+), 6 deletions(-) diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index 0b7b1f6ebd..ecac1556c8 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; @@ -50,7 +51,7 @@ public async Task Invoke(HttpContext httpContext, if (primaryResourceContext != null) { if (!await ValidateContentTypeHeaderAsync(HeaderConstants.MediaType, httpContext, options.SerializerSettings) || - !await ValidateAcceptHeaderAsync(httpContext, options.SerializerSettings)) + !await ValidateAcceptHeaderAsync(_mediaType, httpContext, options.SerializerSettings)) { return; } @@ -61,7 +62,8 @@ public async Task Invoke(HttpContext httpContext, } else if (IsAtomicOperationsRequest(routeValues)) { - if (!await ValidateContentTypeHeaderAsync(HeaderConstants.AtomicOperationsMediaType, httpContext, options.SerializerSettings)) + if (!await ValidateContentTypeHeaderAsync(HeaderConstants.AtomicOperationsMediaType, httpContext, options.SerializerSettings) || + !await ValidateAcceptHeaderAsync(_atomicOperationsMediaType, httpContext, options.SerializerSettings)) { return; } @@ -106,7 +108,7 @@ private static async Task ValidateContentTypeHeaderAsync(string allowedCon 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()) @@ -128,7 +130,7 @@ private static async Task ValidateAcceptHeaderAsync(HttpContext httpContex break; } - if (_mediaType.Equals(headerValue)) + if (allowedMediaTypeValue.Equals(headerValue)) { seenCompatibleMediaType = true; break; @@ -141,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; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs index 3a1019c249..ce8d427b44 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs @@ -4,6 +4,9 @@ using FluentAssertions; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.Extensions.DependencyInjection; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ContentNegotiation @@ -16,6 +19,12 @@ public sealed class AcceptHeaderTests public AcceptHeaderTests(IntegrationTestContext, PolicyDbContext> testContext) { _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + }); } [Fact] @@ -33,6 +42,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 = "/api/v1/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() { @@ -93,6 +137,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 = "/api/v1/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() { @@ -104,7 +190,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 @@ -118,5 +205,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 = "/api/v1/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."); + } } } From 7b5a0d2f9a7112ce2edc66d2ba7eedafae4214b0 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 30 Nov 2020 09:51:09 +0100 Subject: [PATCH 017/123] Moved existing local-id tests --- ...reateResourceWithToOneRelationshipTests.cs | 253 ---------------- .../AtomicOperations/Mixed/LocalIdTests.cs | 284 ++++++++++++++++++ 2 files changed, 284 insertions(+), 253 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/LocalIdTests.cs diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs index 783ee79ef6..4be01fa8cd 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs @@ -191,258 +191,5 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }); } - - [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 = "/api/v1/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 Cannot_consume_unassigned_local_ID() - { - // TODO: @OPS: This can occur at multiple places: in a 'ref', in a to-one relationships, in an element of a to-many relationship etc... - - // 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 - }, - relationships = new - { - ownedBy = new - { - data = new - { - type = "recordCompanies", - lid = "company-1" - } - } - } - } - } - } - }; - - var route = "/api/v1/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 'company-1' is not available at this point."); - responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_consume_local_ID_that_is_assigned_in_same_operation() - { - // 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 - }, - relationships = new - { - ownedBy = new - { - data = new - { - type = "recordCompanies", - lid = trackLocalId - } - } - } - } - } - } - }; - - var route = "/api/v1/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 'track-1' is not available at this point."); - responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [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 = "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 = "/api/v1/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 in use at this point."); - responseDocument.Errors[0].Detail.Should().Be("Another local ID with name 'playlist-1' is already in use at this point."); - responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[1]"); - } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/LocalIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/LocalIdTests.cs new file mode 100644 index 0000000000..2fd33995f3 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/LocalIdTests.cs @@ -0,0 +1,284 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Mixed +{ + public sealed class LocalIdTests + : IClassFixture, OperationsDbContext>> + { + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public LocalIdTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + }); + } + + [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 = "/api/v1/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 Cannot_consume_unassigned_local_ID() + { + // TODO: @OPS: This can occur at multiple places: in a 'ref', in a to-one relationships, in an element of a to-many relationship etc... + + // 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 + }, + relationships = new + { + ownedBy = new + { + data = new + { + type = "recordCompanies", + lid = "company-1" + } + } + } + } + } + } + }; + + var route = "/api/v1/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 'company-1' is not available at this point."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_consume_local_ID_that_is_assigned_in_same_operation() + { + // 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 + }, + relationships = new + { + ownedBy = new + { + data = new + { + type = "recordCompanies", + lid = trackLocalId + } + } + } + } + } + } + }; + + var route = "/api/v1/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 'track-1' is not available at this point."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [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 = "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 = "/api/v1/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 in use at this point."); + responseDocument.Errors[0].Detail.Should().Be("Another local ID with name 'playlist-1' is already in use at this point."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[1]"); + } + } +} From a7c06c644052753994353fb17ebb1b5e92c7a6bd Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 30 Nov 2020 19:23:21 +0100 Subject: [PATCH 018/123] Improvements on local ID usage (see tests). --- .../AtomicOperationsProcessor.cs | 122 +- .../AtomicOperations/ILocalIdTracker.cs | 12 +- .../AtomicOperations/LocalIdTracker.cs | 90 +- .../Processors/AddToRelationshipProcessor.cs | 65 + .../Processors/BaseRelationshipProcessor.cs | 45 + ...erationProcessor.cs => CreateProcessor.cs} | 26 +- ...erationProcessor.cs => DeleteProcessor.cs} | 17 +- ...ssor.cs => IAddToRelationshipProcessor.cs} | 7 +- ...rationProcessor.cs => ICreateProcessor.cs} | 7 +- .../Processors/IDeleteProcessor.cs | 20 + .../IRemoveFromRelationshipProcessor.cs | 20 + ...cessor.cs => ISetRelationshipProcessor.cs} | 7 +- .../Processors/IUpdateProcessor.cs | 21 + .../RemoveFromRelationshipProcessor.cs | 65 + .../Processors/SetRelationshipProcessor.cs | 70 + ...erationProcessor.cs => UpdateProcessor.cs} | 29 +- .../AtomicOperationProcessorResolver.cs | 54 +- .../JsonApiApplicationBuilder.cs | 21 +- .../Repositories/DbContextExtensions.cs | 32 +- .../Serialization/RequestDeserializer.cs | 2 + .../ResponseSerializerFactory.cs | 2 - .../AtomicOperations/Mixed/LocalIdTests.cs | 2037 ++++++++++++++++- .../AtomicOperations/Playlist.cs | 4 +- .../AtomicOperations/RecordCompany.cs | 3 + .../Updating/AtomicUpdateResourceTests.cs | 6 +- .../ReadWrite/Creating/CreateResourceTests.cs | 2 + 26 files changed, 2630 insertions(+), 156 deletions(-) create mode 100644 src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs create mode 100644 src/JsonApiDotNetCore/AtomicOperations/Processors/BaseRelationshipProcessor.cs rename src/JsonApiDotNetCore/AtomicOperations/Processors/{CreateOperationProcessor.cs => CreateProcessor.cs} (71%) rename src/JsonApiDotNetCore/AtomicOperations/Processors/{RemoveOperationProcessor.cs => DeleteProcessor.cs} (68%) rename src/JsonApiDotNetCore/AtomicOperations/Processors/{IRemoveOperationProcessor.cs => IAddToRelationshipProcessor.cs} (54%) rename src/JsonApiDotNetCore/AtomicOperations/Processors/{IUpdateOperationProcessor.cs => ICreateProcessor.cs} (54%) create mode 100644 src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs create mode 100644 src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs rename src/JsonApiDotNetCore/AtomicOperations/Processors/{ICreateOperationProcessor.cs => ISetRelationshipProcessor.cs} (57%) create mode 100644 src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs create mode 100644 src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs create mode 100644 src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs rename src/JsonApiDotNetCore/AtomicOperations/Processors/{UpdateOperationProcessor.cs => UpdateProcessor.cs} (70%) diff --git a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs index 4be644f8dc..d1efb5350d 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs @@ -57,6 +57,9 @@ public async Task> ProcessAsync(IList ProcessOperation(AtomicOperationObject op { cancellationToken.ThrowIfCancellationRequested(); - ReplaceLocalIdsInResourceObject(operation.SingleData); - ReplaceLocalIdInResourceIdentifierObject(operation.Ref); - string resourceName = null; if (operation.Code == AtomicOperationCode.Add || operation.Code == AtomicOperationCode.Update) { - resourceName = operation.SingleData?.Type; - if (resourceName == null) + if (operation.SingleData != null) { - throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + resourceName = operation.SingleData.Type; + if (resourceName == null) { - Title = "The data.type element is required." - }); + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "The data.type element is required." + }); + } + } + else if (operation.ManyData != null) + { + foreach (var resourceObject in operation.ManyData) + { + resourceName = resourceObject.Type; + if (resourceName == null) + { + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "The data.type element is required." + }); + } + } + } + else + { + throw new InvalidOperationException("TODO: Data is missing."); } } @@ -128,6 +149,15 @@ private async Task ProcessOperation(AtomicOperationObject op } } + bool isResourceAdd = operation.Code == AtomicOperationCode.Add && operation.Ref == null; + + if (isResourceAdd && operation.SingleData?.Lid != null) + { + _localIdTracker.Declare(operation.SingleData.Lid, operation.SingleData.Type); + } + + ReplaceLocalIdsInOperationObject(operation, isResourceAdd); + var resourceContext = _resourceContextProvider.GetResourceContext(resourceName); if (resourceContext == null) { @@ -138,18 +168,67 @@ private async Task ProcessOperation(AtomicOperationObject op }); } - ((JsonApiRequest)_request).PrimaryResource = resourceContext; - _targetedFields.Attributes.Clear(); _targetedFields.Relationships.Clear(); + if (operation.Ref?.Relationship != null) + { + var primaryResourceContext = _resourceContextProvider.GetResourceContext(operation.Ref.Type); + var requestRelationship = primaryResourceContext.Relationships.SingleOrDefault(relationship => relationship.PublicName == operation.Ref.Relationship); + + if (requestRelationship == null) + { + throw new InvalidOperationException("TODO: Relationship does not exist."); + } + + ((JsonApiRequest)_request).PrimaryResource = primaryResourceContext; + ((JsonApiRequest)_request).PrimaryId = operation.Ref.Id; + ((JsonApiRequest)_request).Relationship = requestRelationship; + ((JsonApiRequest)_request).SecondaryResource = _resourceContextProvider.GetResourceContext(requestRelationship.RightType); + + _targetedFields.Relationships.Add(_request.Relationship); + } + else + { + ((JsonApiRequest)_request).PrimaryResource = resourceContext; + ((JsonApiRequest)_request).PrimaryId = null; + ((JsonApiRequest)_request).Relationship = null; + ((JsonApiRequest)_request).SecondaryResource = null; + } + var processor = _resolver.ResolveProcessor(operation); return await processor.ProcessAsync(operation, cancellationToken); } - private void ReplaceLocalIdsInResourceObject(ResourceObject resourceObject) + private void ReplaceLocalIdsInOperationObject(AtomicOperationObject operation, bool isResourceAdd) + { + if (operation.Ref != null) + { + ReplaceLocalIdInResourceIdentifierObject(operation.Ref); + } + + if (operation.SingleData != null) + { + ReplaceLocalIdsInResourceObject(operation.SingleData, isResourceAdd); + } + + if (operation.ManyData != null) + { + foreach (var resourceObject in operation.ManyData) + { + ReplaceLocalIdsInResourceObject(resourceObject, isResourceAdd); + } + } + } + + private void ReplaceLocalIdsInResourceObject(ResourceObject resourceObject, bool isResourceAdd) { - if (resourceObject?.Relationships != null) + if (!isResourceAdd) + { + ReplaceLocalIdInResourceIdentifierObject(resourceObject); + } + + if (resourceObject.Relationships != null) { foreach (var relationshipEntry in resourceObject.Relationships.Values) { @@ -164,7 +243,10 @@ private void ReplaceLocalIdsInResourceObject(ResourceObject resourceObject) { var relationship = relationshipEntry.SingleData; - ReplaceLocalIdInResourceIdentifierObject(relationship); + if (relationship != null) + { + ReplaceLocalIdInResourceIdentifierObject(relationship); + } } } } @@ -172,18 +254,10 @@ private void ReplaceLocalIdsInResourceObject(ResourceObject resourceObject) private void ReplaceLocalIdInResourceIdentifierObject(ResourceIdentifierObject resourceIdentifierObject) { - if (resourceIdentifierObject?.Lid != null) + if (resourceIdentifierObject.Lid != null) { - if (!_localIdTracker.IsAssigned(resourceIdentifierObject.Lid)) - { - 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 '{resourceIdentifierObject.Lid}' is not available at this point." - }); - } - - resourceIdentifierObject.Id = _localIdTracker.GetAssignedValue(resourceIdentifierObject.Lid); + resourceIdentifierObject.Id = + _localIdTracker.GetValue(resourceIdentifierObject.Lid, resourceIdentifierObject.Type); } } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs index c43ab00628..dc85d9a0fb 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs @@ -6,18 +6,18 @@ namespace JsonApiDotNetCore.AtomicOperations public interface ILocalIdTracker { /// - /// Assigns a server-generated value to a local ID. + /// Declares a local ID without assigning a server-generated value. /// - void AssignValue(string lid, string id); + void Declare(string lid, string type); /// - /// Gets the server-assigned ID for the specified local ID. + /// Assigns a server-generated ID value to a previously declared local ID. /// - string GetAssignedValue(string lid); + void Assign(string lid, string type, string id); /// - /// Indicates whether a server-generated value is available for the specified local ID. + /// Gets the server-assigned ID for the specified local ID. /// - bool IsAssigned(string lid); + string GetValue(string lid, string type); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs index 6a353f001c..8c011fdf28 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs @@ -1,39 +1,107 @@ 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(); + private readonly IDictionary _idsTracked = new Dictionary(); /// - public void AssignValue(string lid, string id) + public void Declare(string lid, string type) { - if (IsAssigned(lid)) + AssertIsNotDeclared(lid); + + _idsTracked[lid] = new LocalItem(type); + } + + private void AssertIsNotDeclared(string lid) + { + if (_idsTracked.ContainsKey(lid)) + { + 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 '{lid}' is already defined at this point." + }); + } + } + + /// + public void Assign(string lid, string type, string id) + { + AssertIsDeclared(lid); + + var item = _idsTracked[lid]; + + AssertSameResourceType(type, item.Type, lid); + + if (item.IdValue != null) { throw new InvalidOperationException($"Cannot reassign to existing local ID '{lid}'."); } - _idsTracked[lid] = id; + item.IdValue = id; } /// - public string GetAssignedValue(string lid) + public string GetValue(string lid, string type) + { + AssertIsDeclared(lid); + + var item = _idsTracked[lid]; + + AssertSameResourceType(type, item.Type, lid); + + if (item.IdValue == null) + { + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "Local ID cannot be both defined and used within the same operation.", + Detail = $"Local ID '{lid}' cannot be both defined and used within the same operation." + }); + } + + return item.IdValue; + } + + private void AssertIsDeclared(string lid) { - if (!IsAssigned(lid)) + if (!_idsTracked.ContainsKey(lid)) { - throw new InvalidOperationException($"Use of unassigned local ID '{lid}'."); + 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 '{lid}' is not available at this point." + }); } + } - return _idsTracked[lid]; + private static void AssertSameResourceType(string currentType, string declaredType, string lid) + { + if (declaredType != currentType) + { + throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + { + Title = "Type mismatch in local ID usage.", + Detail = $"Local ID '{lid}' belongs to resource type '{declaredType}' instead of '{currentType}'." + }); + } } - /// - public bool IsAssigned(string lid) + private sealed class LocalItem { - return _idsTracked.ContainsKey(lid); + public string Type { get; } + public string IdValue { get; set; } + + public LocalItem(string type) + { + Type = type; + } } } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs new file mode 100644 index 0000000000..f7297b3c93 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs @@ -0,0 +1,65 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + /// Processes a single operation to add resources to a to-many relationship. + /// + /// The resource type. + /// The resource identifier type. + public class AddToRelationshipProcessor + : BaseRelationshipProcessor, IAddToRelationshipProcessor + where TResource : class, IIdentifiable + { + private readonly IAddToRelationshipService _service; + private readonly IJsonApiRequest _request; + + public AddToRelationshipProcessor(IAddToRelationshipService service, + IResourceFactory resourceFactory, IJsonApiRequest request, IJsonApiDeserializer deserializer) + : base(resourceFactory, deserializer, request) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + _request = request ?? throw new ArgumentNullException(nameof(request)); + } + + /// + public async Task ProcessAsync(AtomicOperationObject operation, CancellationToken cancellationToken) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + + if (operation.ManyData == null) + { + throw new InvalidOperationException("TODO: Expected data array. Can we ever get here?"); + } + + var primaryId = GetPrimaryId(operation.Ref.Id); + var secondaryResourceIds = GetSecondaryResourceIds(operation); + + await _service.AddToToManyRelationshipAsync(primaryId, _request.Relationship.PublicName, secondaryResourceIds, cancellationToken); + + return new AtomicResultObject(); + } + } + + /// + /// Processes a single operation to add resources to a to-many relationship. + /// + /// The resource type. + public class AddToRelationshipProcessor + : AddToRelationshipProcessor, IAddToRelationshipProcessor + where TResource : class, IIdentifiable + { + public AddToRelationshipProcessor(IAddToRelationshipService service, + IResourceFactory resourceFactory, IJsonApiRequest request, IJsonApiDeserializer deserializer) + : base(service, resourceFactory, request, deserializer) + { + } + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/BaseRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/BaseRelationshipProcessor.cs new file mode 100644 index 0000000000..84708d3f16 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/BaseRelationshipProcessor.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + public abstract class BaseRelationshipProcessor + { + private readonly IResourceFactory _resourceFactory; + protected readonly IJsonApiDeserializer _deserializer; + private readonly IJsonApiRequest _request; + + protected BaseRelationshipProcessor(IResourceFactory resourceFactory, IJsonApiDeserializer deserializer, + IJsonApiRequest request) + { + _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); + _deserializer = deserializer ?? throw new ArgumentNullException(nameof(deserializer)); + _request = request ?? throw new ArgumentNullException(nameof(request)); + } + + protected TId GetPrimaryId(string stringId) + { + IIdentifiable primaryResource = _resourceFactory.CreateInstance(_request.PrimaryResource.ResourceType); + primaryResource.StringId = stringId; + + return (TId) primaryResource.GetTypedId(); + } + + protected HashSet GetSecondaryResourceIds(AtomicOperationObject operation) + { + var secondaryResourceIds = new HashSet(IdentifiableComparer.Instance); + + foreach (var resourceObject in operation.ManyData) + { + IIdentifiable rightResource = _deserializer.CreateResourceFromObject(resourceObject); + secondaryResourceIds.Add(rightResource); + } + + return secondaryResourceIds; + } + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs similarity index 71% rename from src/JsonApiDotNetCore/AtomicOperations/Processors/CreateOperationProcessor.cs rename to src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs index ea18c3716c..fe6b6d4c72 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateOperationProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs @@ -13,7 +13,7 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors { /// - public class CreateOperationProcessor : ICreateOperationProcessor + public class CreateProcessor : ICreateProcessor where TResource : class, IIdentifiable { private readonly ICreateService _service; @@ -22,7 +22,7 @@ public class CreateOperationProcessor : ICreateOperationProcesso private readonly IResourceObjectBuilder _resourceObjectBuilder; private readonly IResourceContextProvider _resourceContextProvider; - public CreateOperationProcessor(ICreateService service, ILocalIdTracker localIdTracker, IJsonApiDeserializer deserializer, + public CreateProcessor(ICreateService service, ILocalIdTracker localIdTracker, IJsonApiDeserializer deserializer, IResourceObjectBuilder resourceObjectBuilder, IResourceContextProvider resourceContextProvider) { _service = service ?? throw new ArgumentNullException(nameof(service)); @@ -32,25 +32,19 @@ public CreateOperationProcessor(ICreateService service, ILocalId _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); } + /// public async Task ProcessAsync(AtomicOperationObject operation, CancellationToken cancellationToken) { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + var model = (TResource) _deserializer.CreateResourceFromObject(operation.SingleData); var newResource = await _service.CreateAsync(model, cancellationToken); if (operation.SingleData.Lid != null) { - if (_localIdTracker.IsAssigned(operation.SingleData.Lid)) - { - throw new JsonApiException(new Error(HttpStatusCode.BadRequest) - { - Title = "Another local ID with the same name is already in use at this point.", - Detail = $"Another local ID with name '{operation.SingleData.Lid}' is already in use at this point." - }); - } - var serverId = newResource == null ? operation.SingleData.Id : newResource.StringId; - _localIdTracker.AssignValue(operation.SingleData.Lid, serverId); + _localIdTracker.Assign(operation.SingleData.Lid, operation.SingleData.Type, serverId); } if (newResource != null) @@ -70,14 +64,14 @@ public async Task ProcessAsync(AtomicOperationObject operati } /// - /// Processes a single operation with code in a list of atomic operations. + /// Processes a single operation to create a new resource with attributes, relationships or both. /// /// The resource type. - public class CreateOperationProcessor : CreateOperationProcessor, - ICreateOperationProcessor + public class CreateProcessor + : CreateProcessor, ICreateProcessor where TResource : class, IIdentifiable { - public CreateOperationProcessor(ICreateService service, ILocalIdTracker localIdTracker, + public CreateProcessor(ICreateService service, ILocalIdTracker localIdTracker, IJsonApiDeserializer deserializer, IResourceObjectBuilder resourceObjectBuilder, IResourceContextProvider resourceContextProvider) : base(service, localIdTracker, deserializer, resourceObjectBuilder, resourceContextProvider) diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs similarity index 68% rename from src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveOperationProcessor.cs rename to src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs index 2f5cc7bb95..60e817b5b9 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveOperationProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs @@ -10,20 +10,23 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors { /// - public class RemoveOperationProcessor : IRemoveOperationProcessor + public class DeleteProcessor : IDeleteProcessor where TResource : class, IIdentifiable { private readonly IDeleteService _service; - public RemoveOperationProcessor(IDeleteService service) + public DeleteProcessor(IDeleteService service) { _service = service ?? throw new ArgumentNullException(nameof(service)); } + /// public async Task ProcessAsync(AtomicOperationObject operation, CancellationToken cancellationToken) { - var stringId = operation.Ref?.Id; - if (string.IsNullOrWhiteSpace(stringId)) + if (operation == null) throw new ArgumentNullException(nameof(operation)); + + var stringId = operation.Ref.Id; + if (stringId == null) { throw new JsonApiException(new Error(HttpStatusCode.BadRequest) { @@ -39,13 +42,13 @@ public async Task ProcessAsync(AtomicOperationObject operati } /// - /// Processes a single operation with code in a list of atomic operations. + /// Processes a single operation to delete an existing resource. /// /// The resource type. - public class RemoveOperationProcessor : RemoveOperationProcessor, IRemoveOperationProcessor + public class DeleteProcessor : DeleteProcessor, IDeleteProcessor where TResource : class, IIdentifiable { - public RemoveOperationProcessor(IDeleteService service) + public DeleteProcessor(IDeleteService service) : base(service) { } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs similarity index 54% rename from src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveOperationProcessor.cs rename to src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs index 0fd0d0056d..e9cfaf153c 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveOperationProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs @@ -1,20 +1,19 @@ using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.AtomicOperations.Processors { /// - public interface IRemoveOperationProcessor : IRemoveOperationProcessor + public interface IAddToRelationshipProcessor : IAddToRelationshipProcessor where TResource : class, IIdentifiable { } /// - /// Processes a single operation with code in a list of atomic operations. + /// Processes a single operation to add resources to a to-many relationship. /// /// The resource type. /// The resource identifier type. - public interface IRemoveOperationProcessor : IAtomicOperationProcessor + public interface IAddToRelationshipProcessor : IAtomicOperationProcessor where TResource : class, IIdentifiable { } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs similarity index 54% rename from src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateOperationProcessor.cs rename to src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs index 0ddc74b201..9abc968b6f 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateOperationProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs @@ -1,20 +1,19 @@ using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.AtomicOperations.Processors { /// - public interface IUpdateOperationProcessor : IUpdateOperationProcessor + public interface ICreateProcessor : ICreateProcessor where TResource : class, IIdentifiable { } /// - /// Processes a single operation with code in a list of atomic operations. + /// Processes a single operation to create a new resource with attributes, relationships or both. /// /// The resource type. /// The resource identifier type. - public interface IUpdateOperationProcessor : IAtomicOperationProcessor + public interface ICreateProcessor : IAtomicOperationProcessor 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..4e26c8a67a --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs @@ -0,0 +1,20 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + public interface IDeleteProcessor : IDeleteProcessor + where TResource : class, IIdentifiable + { + } + + /// + /// Processes a single operation to delete an existing resource. + /// + /// The resource type. + /// The resource identifier type. + public interface IDeleteProcessor : IAtomicOperationProcessor + where TResource : class, IIdentifiable + { + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs new file mode 100644 index 0000000000..40a87cd5df --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs @@ -0,0 +1,20 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + public interface IRemoveFromRelationshipProcessor : IRemoveFromRelationshipProcessor + where TResource : class, IIdentifiable + { + } + + /// + /// Processes a single operation to remove resources from a to-many relationship. + /// + /// + /// + public interface IRemoveFromRelationshipProcessor : IAtomicOperationProcessor + where TResource : class, IIdentifiable + { + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs similarity index 57% rename from src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateOperationProcessor.cs rename to src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs index 6de983aba2..160215fc36 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateOperationProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs @@ -1,20 +1,19 @@ using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.AtomicOperations.Processors { /// - public interface ICreateOperationProcessor : ICreateOperationProcessor + public interface ISetRelationshipProcessor : ISetRelationshipProcessor where TResource : class, IIdentifiable { } /// - /// Processes a single operation with code in a list of atomic operations. + /// 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 ICreateOperationProcessor : IAtomicOperationProcessor + public interface ISetRelationshipProcessor : IAtomicOperationProcessor 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..f95ca5431d --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + public interface IUpdateProcessor : IUpdateProcessor + where TResource : class, IIdentifiable + { + } + + /// + /// 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 : IAtomicOperationProcessor + 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..037620705a --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs @@ -0,0 +1,65 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Services; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + /// + /// Processes a single operation to remove resources from a to-many relationship. + /// + /// + /// + public class RemoveFromRelationshipProcessor + : BaseRelationshipProcessor, IRemoveFromRelationshipProcessor + where TResource : class, IIdentifiable + { + private readonly IRemoveFromRelationshipService _service; + private readonly IJsonApiRequest _request; + + public RemoveFromRelationshipProcessor(IRemoveFromRelationshipService service, + IResourceFactory resourceFactory, IJsonApiRequest request, IJsonApiDeserializer deserializer) + : base(resourceFactory, deserializer, request) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + _request = request ?? throw new ArgumentNullException(nameof(request)); + } + + /// + public async Task ProcessAsync(AtomicOperationObject operation, CancellationToken cancellationToken) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + + if (operation.ManyData == null) + { + throw new InvalidOperationException("TODO: Expected data array. Can we ever get here?"); + } + + var primaryId = GetPrimaryId(operation.Ref.Id); + var secondaryResourceIds = GetSecondaryResourceIds(operation); + + await _service.RemoveFromToManyRelationshipAsync(primaryId, _request.Relationship.PublicName, secondaryResourceIds, cancellationToken); + + return new AtomicResultObject(); + } + } + + /// + /// Processes a single operation to add resources to a to-many relationship. + /// + /// The resource type. + public class RemoveFromRelationshipProcessor + : RemoveFromRelationshipProcessor, IAddToRelationshipProcessor + where TResource : class, IIdentifiable + { + public RemoveFromRelationshipProcessor(IRemoveFromRelationshipService service, + IResourceFactory resourceFactory, IJsonApiRequest request, IJsonApiDeserializer deserializer) + : base(service, resourceFactory, request, deserializer) + { + } + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs new file mode 100644 index 0000000000..83242480b8 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs @@ -0,0 +1,70 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Services; + +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 class SetRelationshipProcessor + : BaseRelationshipProcessor, ISetRelationshipProcessor + where TResource : class, IIdentifiable + { + private readonly ISetRelationshipService _service; + private readonly IJsonApiRequest _request; + + public SetRelationshipProcessor(ISetRelationshipService service, + IResourceFactory resourceFactory, IJsonApiRequest request, IJsonApiDeserializer deserializer) + : base(resourceFactory, deserializer, request) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + _request = request ?? throw new ArgumentNullException(nameof(request)); + } + + /// + public async Task ProcessAsync(AtomicOperationObject operation, CancellationToken cancellationToken) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + + var primaryId = GetPrimaryId(operation.Ref.Id); + object relationshipValueToAssign = null; + + if (operation.SingleData != null) + { + relationshipValueToAssign = _deserializer.CreateResourceFromObject(operation.SingleData); + } + + if (operation.ManyData != null) + { + relationshipValueToAssign = GetSecondaryResourceIds(operation); + } + + await _service.SetRelationshipAsync(primaryId, _request.Relationship.PublicName, relationshipValueToAssign, cancellationToken); + + return new AtomicResultObject(); + } + } + + /// + /// Processes a single operation to perform a complete replacement of a relationship on an existing resource. + /// + /// The resource type. + public class SetRelationshipProcessor + : SetRelationshipProcessor, IUpdateProcessor + where TResource : class, IIdentifiable + { + public SetRelationshipProcessor(ISetRelationshipService service, + IResourceFactory resourceFactory, IJsonApiRequest request, IJsonApiDeserializer deserializer) + : base(service, resourceFactory, request, deserializer) + { + } + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs similarity index 70% rename from src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateOperationProcessor.cs rename to src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs index 4040f8e704..1dd0b6b37e 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateOperationProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs @@ -13,7 +13,7 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors { /// - public class UpdateOperationProcessor : IUpdateOperationProcessor + public class UpdateProcessor : IUpdateProcessor where TResource : class, IIdentifiable { private readonly IUpdateService _service; @@ -21,7 +21,7 @@ public class UpdateOperationProcessor : IUpdateOperationProcesso private readonly IResourceObjectBuilder _resourceObjectBuilder; private readonly IResourceContextProvider _resourceContextProvider; - public UpdateOperationProcessor(IUpdateService service, IJsonApiDeserializer deserializer, + public UpdateProcessor(IUpdateService service, IJsonApiDeserializer deserializer, IResourceObjectBuilder resourceObjectBuilder, IResourceContextProvider resourceContextProvider) { _service = service ?? throw new ArgumentNullException(nameof(service)); @@ -30,9 +30,18 @@ public UpdateOperationProcessor(IUpdateService service, IJsonApi _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); } - public async Task ProcessAsync(AtomicOperationObject operation, CancellationToken cancellationToken) + /// + public async Task ProcessAsync(AtomicOperationObject operation, + CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(operation?.SingleData?.Id)) + if (operation == null) throw new ArgumentNullException(nameof(operation)); + + if (operation.SingleData == null) + { + throw new InvalidOperationException("TODO: Expected data element. Can we ever get here?"); + } + + if (operation.SingleData.Id == null) { throw new JsonApiException(new Error(HttpStatusCode.BadRequest) { @@ -41,7 +50,6 @@ public async Task ProcessAsync(AtomicOperationObject operati } var model = (TResource) _deserializer.CreateResourceFromObject(operation.SingleData); - var result = await _service.UpdateAsync(model.Id, model, cancellationToken); ResourceObject data = null; @@ -60,15 +68,18 @@ public async Task ProcessAsync(AtomicOperationObject operati } /// - /// Processes a single operation with code in a list of atomic operations. + /// 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. - public class UpdateOperationProcessor : UpdateOperationProcessor, IUpdateOperationProcessor + public class UpdateProcessor + : UpdateProcessor, IUpdateProcessor where TResource : class, IIdentifiable { - public UpdateOperationProcessor(IUpdateService service, IJsonApiDeserializer deserializer, + public UpdateProcessor(IUpdateService service, IJsonApiDeserializer deserializer, IResourceObjectBuilder resourceObjectBuilder, IResourceContextProvider resourceContextProvider) - : base(service, deserializer, resourceObjectBuilder, resourceContextProvider) + : base(service, deserializer, resourceObjectBuilder, + resourceContextProvider) { } } diff --git a/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs b/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs index 6a57300052..fe732ede46 100644 --- a/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs @@ -24,34 +24,46 @@ public IAtomicOperationProcessor ResolveProcessor(AtomicOperationObject operatio { if (operation == null) throw new ArgumentNullException(nameof(operation)); + // TODO: @OPS: How about processors with a single type argument? + + if (operation.Ref?.Relationship != null) + { + switch (operation.Code) + { + case AtomicOperationCode.Add: + { + return Resolve(operation, typeof(IAddToRelationshipProcessor<,>)); + } + case AtomicOperationCode.Update: + { + return Resolve(operation, typeof(ISetRelationshipProcessor<,>)); + } + case AtomicOperationCode.Remove: + { + return Resolve(operation, typeof(IRemoveFromRelationshipProcessor<,>)); + } + } + } + switch (operation.Code) { - case AtomicOperationCode.Add: - return ResolveCreateProcessor(operation); - case AtomicOperationCode.Remove: - return ResolveRemoveProcessor(operation); - case AtomicOperationCode.Update: - return ResolveUpdateProcessor(operation); + case AtomicOperationCode.Add: + { + return Resolve(operation, typeof(ICreateProcessor<,>)); + } + case AtomicOperationCode.Update: + { + return Resolve(operation, typeof(IUpdateProcessor<,>)); + } + case AtomicOperationCode.Remove: + { + return Resolve(operation, typeof(IDeleteProcessor<,>)); + } } throw new InvalidOperationException($"Atomic operation code '{operation.Code}' is invalid."); } - private IAtomicOperationProcessor ResolveCreateProcessor(AtomicOperationObject operation) - { - return Resolve(operation, typeof(ICreateOperationProcessor<,>)); - } - - private IAtomicOperationProcessor ResolveRemoveProcessor(AtomicOperationObject operation) - { - return Resolve(operation, typeof(IRemoveOperationProcessor<,>)); - } - - private IAtomicOperationProcessor ResolveUpdateProcessor(AtomicOperationObject operation) - { - return Resolve(operation, typeof(IUpdateOperationProcessor<,>)); - } - private IAtomicOperationProcessor Resolve(AtomicOperationObject atomicOperationObject, Type processorInterface) { var resourceName = atomicOperationObject.GetResourceTypeName(); diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index fcd6189c37..43f195426f 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -277,14 +277,23 @@ private void AddAtomicOperationsLayer() _services.AddScoped(); _services.AddScoped(); - _services.AddScoped(typeof(ICreateOperationProcessor<>), typeof(CreateOperationProcessor<>)); - _services.AddScoped(typeof(ICreateOperationProcessor<,>), typeof(CreateOperationProcessor<,>)); + _services.AddScoped(typeof(ICreateProcessor<>), typeof(CreateProcessor<>)); + _services.AddScoped(typeof(ICreateProcessor<,>), typeof(CreateProcessor<,>)); - _services.AddScoped(typeof(IRemoveOperationProcessor<>), typeof(RemoveOperationProcessor<>)); - _services.AddScoped(typeof(IRemoveOperationProcessor<,>), typeof(RemoveOperationProcessor<,>)); + _services.AddScoped(typeof(IUpdateProcessor<>), typeof(UpdateProcessor<>)); + _services.AddScoped(typeof(IUpdateProcessor<,>), typeof(UpdateProcessor<,>)); - _services.AddScoped(typeof(IUpdateOperationProcessor<>), typeof(UpdateOperationProcessor<>)); - _services.AddScoped(typeof(IUpdateOperationProcessor<,>), typeof(UpdateOperationProcessor<,>)); + _services.AddScoped(typeof(IDeleteProcessor<>), typeof(DeleteProcessor<>)); + _services.AddScoped(typeof(IDeleteProcessor<,>), typeof(DeleteProcessor<,>)); + + _services.AddScoped(typeof(IAddToRelationshipProcessor<>), typeof(AddToRelationshipProcessor<>)); + _services.AddScoped(typeof(IAddToRelationshipProcessor<,>), typeof(AddToRelationshipProcessor<,>)); + + _services.AddScoped(typeof(ISetRelationshipProcessor<>), typeof(SetRelationshipProcessor<>)); + _services.AddScoped(typeof(ISetRelationshipProcessor<,>), typeof(SetRelationshipProcessor<,>)); + + _services.AddScoped(typeof(IRemoveFromRelationshipProcessor<>), typeof(RemoveFromRelationshipProcessor<>)); + _services.AddScoped(typeof(IRemoveFromRelationshipProcessor<,>), typeof(RemoveFromRelationshipProcessor<,>)); } private void AddResourcesFromDbContext(DbContext dbContext, ResourceGraphBuilder builder) diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index 2eeccf858f..daf632702e 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -1,9 +1,11 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using JsonApiDotNetCore.Resources; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage; @@ -47,24 +49,22 @@ public static object GetTrackedIdentifiable(this DbContext dbContext, IIdentifia return entityEntry?.Entity; } + // TODO: @OPS: Should we keep this? /// - /// Gets the current transaction or creates a new one. - /// If a transaction already exists, commit, rollback and dispose - /// will not be called. It is assumed the creator of the original - /// transaction should be responsible for disposal. + /// Detaches all entities from the change tracker. /// - /// - /// - /// - /// using(var transaction = _context.GetCurrentOrCreateTransaction()) - /// { - /// // perform multiple operations on the context and then save... - /// _context.SaveChanges(); - /// } - /// - /// - public static async Task GetCurrentOrCreateTransactionAsync(this DbContext context) - => await SafeTransactionProxy.GetOrCreateAsync(context.Database); + public static void ResetChangeTracker(this DbContext dbContext) + { + if (dbContext == null) throw new ArgumentNullException(nameof(dbContext)); + + List entriesWithChanges = dbContext.ChangeTracker.Entries().Where(entry => + entry.State != EntityState.Detached).ToList(); + + foreach (EntityEntry entry in entriesWithChanges) + { + entry.State = EntityState.Detached; + } + } } /// diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index 85016c3f12..39e819b349 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -70,6 +70,8 @@ public object DeserializeOperationsDocument(string body) public IIdentifiable CreateResourceFromObject(ResourceObject data) { + if (data == null) throw new ArgumentNullException(nameof(data)); + return ParseResourceObject(data); } diff --git a/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs b/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs index 61eeba76e6..386fd2bf8b 100644 --- a/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs +++ b/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs @@ -1,9 +1,7 @@ using System; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Serialization.Building; using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; namespace JsonApiDotNetCore.Serialization { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/LocalIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/LocalIdTests.cs index 2fd33995f3..7e3f6bd0a6 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/LocalIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/LocalIdTests.cs @@ -121,17 +121,32 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_consume_unassigned_local_ID() + public async Task Can_create_resource_with_OneToMany_relationship_using_local_ID() { - // TODO: @OPS: This can occur at multiple places: in a 'ref', in a to-one relationships, in an element of a to-many relationship etc... - // 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", @@ -144,12 +159,15 @@ public async Task Cannot_consume_unassigned_local_ID() }, relationships = new { - ownedBy = new + performers = new { - data = new + data = new[] { - type = "recordCompanies", - lid = "company-1" + new + { + type = "performers", + lid = performerLocalId + } } } } @@ -161,23 +179,49 @@ public async Task Cannot_consume_unassigned_local_ID() var route = "/api/v1/operations"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - 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 'company-1' is not available at this point."); - responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + 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 Cannot_consume_local_ID_that_is_assigned_in_same_operation() + 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 @@ -194,15 +238,101 @@ public async Task Cannot_consume_local_ID_that_is_assigned_in_same_operation() attributes = new { title = newTrackTitle + } + } + }, + new + { + op = "add", + data = new + { + type = "playlists", + attributes = new + { + name = newPlaylistName }, relationships = new { - ownedBy = new + tracks = new + { + data = new[] + { + new + { + type = "musicTracks", + lid = trackLocalId + } + } + } + } + } + } + } + }; + + var route = "/api/v1/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 = "add", + data = new + { + type = "recordCompanies", + lid = companyLocalId, + relationships = new + { + parent = new { data = new { type = "recordCompanies", - lid = trackLocalId + lid = companyLocalId } } } @@ -221,8 +351,8 @@ public async Task Cannot_consume_local_ID_that_is_assigned_in_same_operation() 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 'track-1' is not available at this point."); + 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[0]"); } @@ -276,8 +406,1873 @@ public async Task Cannot_reassign_local_ID() 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 in use at this point."); - responseDocument.Errors[0].Detail.Should().Be("Another local ID with name 'playlist-1' is already in use at this point."); + 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[1]"); + } + + [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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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(Skip = "TODO: Make this test work.")] + public async Task Can_add_to_ManyToMany_relationship_using_local_ID() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + 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.Add(existingTrack); + 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 = existingTrack.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 + } + } + } + } + }; + + var route = "/api/v1/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(2); + playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(existingTrack.Id); + playlistInDatabase.PlaylistMusicTracks[1].MusicTrack.Id.Should().Be(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 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 = "performers", + lid = performerLocalId, + attributes = new + { + artistName = newArtistName + } + } + }, + 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 = performerLocalId + } + } + } + } + } + }, + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + lid = trackLocalId, + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers", + lid = performerLocalId + } + } + } + } + }; + + var route = "/api/v1/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("performers"); + responseDocument.Results[0].SingleData.Lid.Should().BeNull(); + responseDocument.Results[0].SingleData.Attributes["artistName"].Should().Be(newArtistName); + + 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 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(existingPerformer.Id); + trackInDatabase.Performers[0].ArtistName.Should().Be(existingPerformer.ArtistName); + }); + } + + [Fact(Skip = "TODO: Make this test work.")] + public async Task Can_remove_from_ManyToMany_relationship_using_local_ID() + { + // Arrange + var existingTrack = _fakers.MusicTrack.Generate(); + + 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.Add(existingTrack); + 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", + data = new + { + type = "playlists", + lid = playlistLocalId, + attributes = new + { + name = newPlaylistName + }, + relationships = new + { + tracks = new + { + data = new object[] + { + new + { + type = "musicTracks", + id = existingTrack.StringId + }, + new + { + type = "musicTracks", + lid = trackLocalId + } + } + } + } + } + }, + new + { + op = "remove", + @ref = new + { + type = "playlists", + lid = playlistLocalId, + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + lid = trackLocalId + } + } + } + } + }; + + var route = "/api/v1/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("playlists"); + responseDocument.Results[1].SingleData.Lid.Should().BeNull(); + responseDocument.Results[1].SingleData.Attributes["name"].Should().Be(newPlaylistName); + + responseDocument.Results[2].Data.Should().BeNull(); + + 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(existingTrack.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 = "/api/v1/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 = "musicTracks", + lid = "doesNotExist" + } + } + } + }; + + var route = "/api/v1/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[0]"); + } + + [Fact] + public async Task Cannot_consume_unassigned_local_ID_in_data_element() + { + // Arrange + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + lid = "doesNotExist", + attributes = new + { + } + } + } + } + }; + + var route = "/api/v1/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[0]"); + } + + [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 = "add", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers", + lid = "doesNotExist" + } + } + } + } + }; + + var route = "/api/v1/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[0]"); + } + + [Fact] + public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_element() + { + // Arrange + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + relationships = new + { + ownedBy = new + { + data = new + { + type = "recordCompanies", + lid = "doesNotExist" + } + } + } + } + } + } + }; + + var route = "/api/v1/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[0]"); + } + + [Fact] + public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_array() + { + // Arrange + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "playlists", + relationships = new + { + tracks = new + { + data = new[] + { + new + { + type = "musicTracks", + lid = "doesNotExist" + } + } + } + } + } + } + } + }; + + var route = "/api/v1/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[0]"); + } + + [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 = "add", + data = new + { + type = "musicTracks", + lid = trackLocalId, + relationships = new + { + ownedBy = new + { + data = new + { + type = "recordCompanies", + lid = trackLocalId + } + } + } + } + } + } + }; + + var route = "/api/v1/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[0]"); + } + + [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 = "add", + data = new + { + type = "recordCompanies", + lid = companyLocalId + } + }, + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + lid = companyLocalId + } + } + } + }; + + var route = "/api/v1/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[1]"); + } + + [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 = "add", + data = new + { + type = "performers", + lid = performerLocalId + } + }, + new + { + op = "update", + data = new + { + type = "playlists", + lid = performerLocalId, + attributes = new + { + } + } + } + } + }; + + var route = "/api/v1/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[1]"); + } + + [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 = "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 = "/api/v1/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[1]"); + } + + [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 = "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 = "/api/v1/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[1]"); + } + + [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 = "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 = "/api/v1/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[1]"); } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Playlist.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Playlist.cs index a89adb295b..8d88898b0c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Playlist.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Playlist.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using JsonApiDotNetCore.Resources; @@ -14,7 +14,7 @@ public sealed class Playlist : Identifiable [NotMapped] [HasManyThrough(nameof(PlaylistMusicTracks))] - public IList MusicTracks { get; set; } + public IList Tracks { get; set; } public IList PlaylistMusicTracks { get; set; } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/RecordCompany.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/RecordCompany.cs index 2b79154d79..5dc89a7a87 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/RecordCompany.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/RecordCompany.cs @@ -14,5 +14,8 @@ public sealed class RecordCompany : Identifiable [HasMany] public IList Tracks { get; set; } + + [HasOne] + public RecordCompany Parent { get; set; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/AtomicUpdateResourceTests.cs index 7b008036ea..a0e96d609b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/AtomicUpdateResourceTests.cs @@ -73,7 +73,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].SingleData.Should().BeNull(); + responseDocument.Results[0].Data.Should().BeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -133,7 +133,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].SingleData.Should().BeNull(); + responseDocument.Results[0].Data.Should().BeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -200,7 +200,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => for (int index = 0; index < elementCount; index++) { - responseDocument.Results[index].SingleData.Should().BeNull(); + responseDocument.Results[index].Data.Should().BeNull(); } await _testContext.RunOnDatabaseAsync(async dbContext => diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs index da2ecd70d6..fe2d33561b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs @@ -27,6 +27,8 @@ public CreateResourceTests(IntegrationTestContext Date: Tue, 1 Dec 2020 13:34:16 +0100 Subject: [PATCH 019/123] Block query string parameters on atomic:operations request --- .../FilterQueryStringParameterReader.cs | 3 +- .../IncludeQueryStringParameterReader.cs | 3 +- .../PaginationQueryStringParameterReader.cs | 3 +- .../Internal/QueryStringParameterReader.cs | 2 + ...ourceDefinitionQueryableParameterReader.cs | 5 + .../SortQueryStringParameterReader.cs | 3 +- ...parseFieldSetQueryStringParameterReader.cs | 3 +- .../FrozenSystemClock.cs | 20 + .../Creating/AtomicCreateResourceTests.cs | 2 +- ...{LocalIdTests.cs => AtomicLocalIdTests.cs} | 4 +- .../MusicTrackResourceDefinition.cs | 40 ++ .../AtomicOperations/MusicTracksController.cs | 17 + .../QueryStrings/AtomicQueryStringTests.cs | 420 ++++++++++++++++++ .../ObjectAssertionsExtensions.cs | 2 +- test/UnitTests/FrozenSystemClock.cs | 2 +- 15 files changed, 519 insertions(+), 10 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/FrozenSystemClock.cs rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/{LocalIdTests.cs => AtomicLocalIdTests.cs} (99%) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrackResourceDefinition.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTracksController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs 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/test/JsonApiDotNetCoreExampleTests/FrozenSystemClock.cs b/test/JsonApiDotNetCoreExampleTests/FrozenSystemClock.cs new file mode 100644 index 0000000000..2bf19b251d --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/FrozenSystemClock.cs @@ -0,0 +1,20 @@ +using System; +using Microsoft.AspNetCore.Authentication; + +namespace JsonApiDotNetCoreExampleTests +{ + internal sealed class FrozenSystemClock : ISystemClock + { + public DateTimeOffset UtcNow { get; } + + public FrozenSystemClock() + : this(new DateTimeOffset(new DateTime(2000, 1, 1))) + { + } + + public FrozenSystemClock(DateTimeOffset utcNow) + { + UtcNow = utcNow; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index 8e39071562..dcf43431af 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -182,7 +182,7 @@ public async Task Can_create_resources() 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"].Should().BeApproximately(newTracks[index].LengthInSeconds, 0.00000000001M); + responseDocument.Results[index].SingleData.Attributes["lengthInSeconds"].Should().BeApproximately(newTracks[index].LengthInSeconds); responseDocument.Results[index].SingleData.Attributes["genre"].Should().Be(newTracks[index].Genre); responseDocument.Results[index].SingleData.Attributes["releasedAt"].Should().BeCloseTo(newTracks[index].ReleasedAt); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/LocalIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/LocalIdTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs index 7e3f6bd0a6..33add4c6ee 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/LocalIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs @@ -11,13 +11,13 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Mixed { - public sealed class LocalIdTests + public sealed class AtomicLocalIdTests : IClassFixture, OperationsDbContext>> { private readonly IntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public LocalIdTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicLocalIdTests(IntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrackResourceDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrackResourceDefinition.cs new file mode 100644 index 0000000000..2606d29b58 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrackResourceDefinition.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 +{ + public sealed class MusicTrackResourceDefinition : JsonApiResourceDefinition + { + private readonly ISystemClock _systemClock; + + public MusicTrackResourceDefinition(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/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/QueryStrings/AtomicQueryStringTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs new file mode 100644 index 0000000000..b6164f70ab --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs @@ -0,0 +1,420 @@ +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 JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.Extensions.DependencyInjection; +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 IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicQueryStringTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + + services.AddSingleton(new FrozenSystemClock(_frozenTime)); + services.AddScoped, MusicTrackResourceDefinition>(); + }); + + 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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/operations?fields=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' cannot be used at this endpoint."); + responseDocument.Errors[0].Source.Parameter.Should().Be("fields"); + } + + [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 = "/api/v1/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 = "/api/v1/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"].Should().BeApproximately(newTrackLength); + } + + [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 = "/api/v1/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"].Should().BeApproximately(newTrackLength); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs index f85c19f489..a1c7c9d5e2 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs @@ -28,7 +28,7 @@ public static void BeCloseTo(this ObjectAssertions source, DateTimeOffset? expec /// /// Used to assert on a column, whose value is returned as in json:api response body. /// - public static void BeApproximately(this ObjectAssertions source, decimal expected, decimal precision, string because = "", + public static void BeApproximately(this ObjectAssertions source, decimal expected, decimal precision = 0.00000000001M, string because = "", params object[] becauseArgs) { // We lose a little bit of precision on roundtrip through PostgreSQL database. diff --git a/test/UnitTests/FrozenSystemClock.cs b/test/UnitTests/FrozenSystemClock.cs index 218fe1cb54..6fa603c525 100644 --- a/test/UnitTests/FrozenSystemClock.cs +++ b/test/UnitTests/FrozenSystemClock.cs @@ -3,7 +3,7 @@ namespace UnitTests { - internal class FrozenSystemClock : ISystemClock + internal sealed class FrozenSystemClock : ISystemClock { public DateTimeOffset UtcNow { get; } From 21c4d30080a325b2d5d0f8cd61a8ed378c42dba1 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 1 Dec 2020 14:51:49 +0100 Subject: [PATCH 020/123] Fixed broken tests --- .../AtomicOperations/Mixed/AtomicLocalIdTests.cs | 9 ++++----- .../AtomicOperations/PlaylistMusicTrack.cs | 6 ++++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs index 33add4c6ee..733b5c6c22 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs @@ -1290,7 +1290,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [Fact(Skip = "TODO: Make this test work.")] + [Fact] public async Task Can_add_to_ManyToMany_relationship_using_local_ID() { // Arrange @@ -1408,8 +1408,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => playlistInDatabase.Name.Should().Be(newPlaylistName); playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(2); - playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(existingTrack.Id); - playlistInDatabase.PlaylistMusicTracks[1].MusicTrack.Id.Should().Be(newTrackId); + playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTrack.Id); + playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == newTrackId); }); } @@ -1534,13 +1534,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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(Skip = "TODO: Make this test work.")] + [Fact] public async Task Can_remove_from_ManyToMany_relationship_using_local_ID() { // Arrange diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PlaylistMusicTrack.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PlaylistMusicTrack.cs index 391c624ec4..9c5389867a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PlaylistMusicTrack.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PlaylistMusicTrack.cs @@ -1,11 +1,13 @@ -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations +using System; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations { public sealed class PlaylistMusicTrack { public long PlaylistId { get; set; } public Playlist Playlist { get; set; } - public long MusicTrackId { get; set; } + public Guid MusicTrackId { get; set; } public MusicTrack MusicTrack { get; set; } } } From aa5e4545856f0ff30466d6024b0ecc1ffe717b45 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 1 Dec 2020 15:22:47 +0100 Subject: [PATCH 021/123] Return NoContent when data for all operations is null --- .../BaseJsonApiAtomicOperationsController.cs | 12 ++++++--- .../Deleting/AtomicDeleteResourceTests.cs | 18 +++++-------- .../Updating/AtomicUpdateResourceTests.cs | 25 +++++++------------ 3 files changed, 24 insertions(+), 31 deletions(-) diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs index 147f388cb5..1df86bce31 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading; using System.Threading.Tasks; using JsonApiDotNetCore.AtomicOperations; @@ -70,10 +71,15 @@ public virtual async Task PostOperationsAsync([FromBody] AtomicOp var results = await _processor.ProcessAsync(document.Operations, cancellationToken); - return Ok(new AtomicOperationsDocument + if (results.Any(result => result.Data != null)) { - Results = results - }); + return Ok(new AtomicOperationsDocument + { + Results = results + }); + } + + return NoContent(); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs index adf0cbc3d2..3620536b33 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs @@ -59,13 +59,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/api/v1/operations"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.Should().BeNull(); + responseDocument.Should().BeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -113,17 +112,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/api/v1/operations"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Results.Should().HaveCount(elementCount); - - for (int index = 0; index < elementCount; index++) - { - responseDocument.Results[index].Data.Should().BeNull(); - } + responseDocument.Should().BeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/AtomicUpdateResourceTests.cs index a0e96d609b..f9d598e47e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/AtomicUpdateResourceTests.cs @@ -67,13 +67,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/api/v1/operations"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.Should().BeNull(); + responseDocument.Should().BeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -127,13 +126,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/api/v1/operations"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.Should().BeNull(); + responseDocument.Should().BeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -191,17 +189,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/api/v1/operations"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - responseDocument.Results.Should().HaveCount(elementCount); - - for (int index = 0; index < elementCount; index++) - { - responseDocument.Results[index].Data.Should().BeNull(); - } + responseDocument.Should().BeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => { From 01e113c90dc2c49e9ebbb7570ebcbce1508c67c8 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 1 Dec 2020 15:29:12 +0100 Subject: [PATCH 022/123] Corrected example --- .../BaseJsonApiAtomicOperationsController.cs | 47 ++++++++++++++++--- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs index 1df86bce31..39ee85feee 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs @@ -33,17 +33,17 @@ protected BaseJsonApiAtomicOperationsController(IJsonApiOptions options, ILogger /// /// Processes a document with atomic operations and returns their results. + /// If none of the operations contain data, HTTP 201 is returned instead 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] AtomicOperationsDocument document, CancellationToken cancellationToken) { From 6b74ded73c77d05f67e44b81d30260145df660b0 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 1 Dec 2020 15:58:50 +0100 Subject: [PATCH 023/123] Fail on local ID usage outside of operations --- .../Serialization/BaseDeserializer.cs | 21 +++++++-- .../Serialization/RequestDeserializer.cs | 2 +- .../ReadWrite/Creating/CreateResourceTests.cs | 2 - ...eateResourceWithToManyRelationshipTests.cs | 43 +++++++++++++++++++ ...reateResourceWithToOneRelationshipTests.cs | 40 +++++++++++++++++ 5 files changed, 102 insertions(+), 6 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index 58141d306b..094321322d 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -52,12 +52,14 @@ protected object DeserializeBody(string body) { if (Document.IsManyData) { - return Document.ManyData.Select(ParseResourceObject).ToHashSet(IdentifiableComparer.Instance); + return Document.ManyData + .Select(data => ParseResourceObject(data, false)) + .ToHashSet(IdentifiableComparer.Instance); } if (Document.SingleData != null) { - return ParseResourceObject(Document.SingleData); + return ParseResourceObject(Document.SingleData, false); } } @@ -150,10 +152,15 @@ protected JToken LoadJToken(string body) /// and sets its attributes and relationships. /// /// The parsed resource. - protected IIdentifiable ParseResourceObject(ResourceObject data) + protected IIdentifiable ParseResourceObject(ResourceObject data, bool allowLocalIds) { AssertHasType(data, null); + if (!allowLocalIds) + { + AssertHasNoLocalId(data); + } + var resourceContext = GetExistingResourceContext(data.Type); var resource = ResourceFactory.CreateInstance(resourceContext.ResourceType); @@ -253,6 +260,14 @@ private void AssertHasType(ResourceIdentifierObject resourceIdentifierObject, Re } } + private void AssertHasNoLocalId(ResourceIdentifierObject resourceIdentifierObject) + { + if (resourceIdentifierObject.Lid != null) + { + throw new JsonApiSerializationException("Local IDs cannot be used at this endpoint.", null); + } + } + private void AssertHasId(ResourceIdentifierObject resourceIdentifierObject, RelationshipAttribute relationship) { if (resourceIdentifierObject.Id == null) diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index 39e819b349..3ab23b40be 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -72,7 +72,7 @@ public IIdentifiable CreateResourceFromObject(ResourceObject data) { if (data == null) throw new ArgumentNullException(nameof(data)); - return ParseResourceObject(data); + return ParseResourceObject(data, true); } private void AssertResourceIdIsNotTargeted() diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs index fe2d33561b..da2ecd70d6 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs @@ -27,8 +27,6 @@ public CreateResourceTests(IntegrationTestContext(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 2001171eaf..f6ccdf172e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs @@ -587,5 +587,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: <<"); + } } } From e0f6482f73807636d24bc77f9c3dbb568b14b10f Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 1 Dec 2020 18:56:34 +0100 Subject: [PATCH 024/123] Added ModelState validation --- .../AtomicOperationsProcessor.cs | 4 +- .../Processors/AddToRelationshipProcessor.cs | 2 +- .../BaseAtomicOperationProcessor.cs | 35 ++ .../Processors/CreateProcessor.cs | 26 +- .../Processors/DeleteProcessor.cs | 2 +- .../RemoveFromRelationshipProcessor.cs | 2 +- .../Processors/SetRelationshipProcessor.cs | 2 +- .../Processors/UpdateProcessor.cs | 25 +- .../Configuration/JsonApiValidationFilter.cs | 10 +- .../BaseJsonApiAtomicOperationsController.cs | 5 - .../Middleware/IJsonApiRequest.cs | 6 + .../Middleware/JsonApiRequest.cs | 4 + .../AtomicModelStateValidationStartup.cs | 19 + .../AtomicModelStateValidationTests.cs | 433 ++++++++++++++++++ .../AtomicOperations/MusicTrack.cs | 5 +- .../ObjectAssertionsExtensions.cs | 4 +- 16 files changed, 552 insertions(+), 32 deletions(-) create mode 100644 src/JsonApiDotNetCore/AtomicOperations/Processors/BaseAtomicOperationProcessor.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationStartup.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs diff --git a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs index d1efb5350d..7b1a6d8d17 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs @@ -77,7 +77,7 @@ public async Task> ProcessAsync(IList ProcessOperation(AtomicOperationObject op ((JsonApiRequest)_request).SecondaryResource = null; } + ((JsonApiRequest) _request).OperationCode = operation.Code; + var processor = _resolver.ResolveProcessor(operation); return await processor.ProcessAsync(operation, cancellationToken); } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs index f7297b3c93..6c1db2c1af 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs @@ -56,7 +56,7 @@ public class AddToRelationshipProcessor : AddToRelationshipProcessor, IAddToRelationshipProcessor where TResource : class, IIdentifiable { - public AddToRelationshipProcessor(IAddToRelationshipService service, + public AddToRelationshipProcessor(IAddToRelationshipService service, IResourceFactory resourceFactory, IJsonApiRequest request, IJsonApiDeserializer deserializer) : base(service, resourceFactory, request, deserializer) { diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/BaseAtomicOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/BaseAtomicOperationProcessor.cs new file mode 100644 index 0000000000..1b3a1e863e --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/BaseAtomicOperationProcessor.cs @@ -0,0 +1,35 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; + +namespace JsonApiDotNetCore.AtomicOperations.Processors +{ + public abstract class BaseAtomicOperationProcessor + { + private readonly IJsonApiOptions _options; + private readonly IObjectModelValidator _validator; + + protected BaseAtomicOperationProcessor(IJsonApiOptions options, IObjectModelValidator validator) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _validator = validator ?? throw new ArgumentNullException(nameof(validator)); + } + + protected void ValidateModelState(TResource model) + { + if (_options.ValidateModelState) + { + var actionContext = new ActionContext(); + _validator.Validate(actionContext, null, string.Empty, model); + + if (!actionContext.ModelState.IsValid) + { + var namingStrategy = _options.SerializerContractResolver.NamingStrategy; + throw new InvalidModelStateException(actionContext.ModelState, typeof(TResource), _options.IncludeExceptionStackTraceInErrors, namingStrategy); + } + } + } + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs index fe6b6d4c72..53a35e7cbf 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs @@ -1,19 +1,22 @@ using System; -using System.Net; using System.Threading; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Building; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; namespace JsonApiDotNetCore.AtomicOperations.Processors { - /// - public class CreateProcessor : ICreateProcessor + /// + /// Processes a single operation to create a new resource with attributes, relationships or both. + /// + /// The resource type. + /// The resource identifier type. + public class CreateProcessor : BaseAtomicOperationProcessor, ICreateProcessor where TResource : class, IIdentifiable { private readonly ICreateService _service; @@ -22,8 +25,10 @@ public class CreateProcessor : ICreateProcessor private readonly IResourceObjectBuilder _resourceObjectBuilder; private readonly IResourceContextProvider _resourceContextProvider; - public CreateProcessor(ICreateService service, ILocalIdTracker localIdTracker, IJsonApiDeserializer deserializer, + public CreateProcessor(ICreateService service, IJsonApiOptions options, + IObjectModelValidator validator, ILocalIdTracker localIdTracker, IJsonApiDeserializer deserializer, IResourceObjectBuilder resourceObjectBuilder, IResourceContextProvider resourceContextProvider) + : base(options, validator) { _service = service ?? throw new ArgumentNullException(nameof(service)); _localIdTracker = localIdTracker ?? throw new ArgumentNullException(nameof(localIdTracker)); @@ -39,6 +44,8 @@ public async Task ProcessAsync(AtomicOperationObject operati if (operation == null) throw new ArgumentNullException(nameof(operation)); var model = (TResource) _deserializer.CreateResourceFromObject(operation.SingleData); + ValidateModelState(model); + var newResource = await _service.CreateAsync(model, cancellationToken); if (operation.SingleData.Lid != null) @@ -71,10 +78,11 @@ public class CreateProcessor : CreateProcessor, ICreateProcessor where TResource : class, IIdentifiable { - public CreateProcessor(ICreateService service, ILocalIdTracker localIdTracker, - IJsonApiDeserializer deserializer, IResourceObjectBuilder resourceObjectBuilder, - IResourceContextProvider resourceContextProvider) - : base(service, localIdTracker, deserializer, resourceObjectBuilder, resourceContextProvider) + public CreateProcessor(ICreateService service, IJsonApiOptions options, + IObjectModelValidator validator, ILocalIdTracker localIdTracker, IJsonApiDeserializer deserializer, + IResourceObjectBuilder resourceObjectBuilder, IResourceContextProvider resourceContextProvider) + : base(service, options, validator, localIdTracker, deserializer, resourceObjectBuilder, + resourceContextProvider) { } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs index 60e817b5b9..17e409b5b6 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs @@ -48,7 +48,7 @@ public async Task ProcessAsync(AtomicOperationObject operati public class DeleteProcessor : DeleteProcessor, IDeleteProcessor where TResource : class, IIdentifiable { - public DeleteProcessor(IDeleteService service) + public DeleteProcessor(IDeleteService service) : base(service) { } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs index 037620705a..1c2921d22a 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs @@ -56,7 +56,7 @@ public class RemoveFromRelationshipProcessor : RemoveFromRelationshipProcessor, IAddToRelationshipProcessor where TResource : class, IIdentifiable { - public RemoveFromRelationshipProcessor(IRemoveFromRelationshipService service, + public RemoveFromRelationshipProcessor(IRemoveFromRelationshipService service, IResourceFactory resourceFactory, IJsonApiRequest request, IJsonApiDeserializer deserializer) : base(service, resourceFactory, request, deserializer) { diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs index 83242480b8..27ed541ff8 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs @@ -61,7 +61,7 @@ public class SetRelationshipProcessor : SetRelationshipProcessor, IUpdateProcessor where TResource : class, IIdentifiable { - public SetRelationshipProcessor(ISetRelationshipService service, + public SetRelationshipProcessor(ISetRelationshipService service, IResourceFactory resourceFactory, IJsonApiRequest request, IJsonApiDeserializer deserializer) : base(service, resourceFactory, request, deserializer) { diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs index 1dd0b6b37e..7b2b41fd21 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs @@ -9,11 +9,17 @@ using JsonApiDotNetCore.Serialization.Building; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; namespace JsonApiDotNetCore.AtomicOperations.Processors { - /// - public class UpdateProcessor : IUpdateProcessor + /// + /// 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 class UpdateProcessor : BaseAtomicOperationProcessor, IUpdateProcessor where TResource : class, IIdentifiable { private readonly IUpdateService _service; @@ -21,8 +27,10 @@ public class UpdateProcessor : IUpdateProcessor private readonly IResourceObjectBuilder _resourceObjectBuilder; private readonly IResourceContextProvider _resourceContextProvider; - public UpdateProcessor(IUpdateService service, IJsonApiDeserializer deserializer, + public UpdateProcessor(IUpdateService service, IJsonApiOptions options, + IObjectModelValidator validator, IJsonApiDeserializer deserializer, IResourceObjectBuilder resourceObjectBuilder, IResourceContextProvider resourceContextProvider) + : base(options, validator) { _service = service ?? throw new ArgumentNullException(nameof(service)); _deserializer = deserializer ?? throw new ArgumentNullException(nameof(deserializer)); @@ -31,8 +39,7 @@ public UpdateProcessor(IUpdateService service, IJsonApiDeseriali } /// - public async Task ProcessAsync(AtomicOperationObject operation, - CancellationToken cancellationToken) + public async Task ProcessAsync(AtomicOperationObject operation, CancellationToken cancellationToken) { if (operation == null) throw new ArgumentNullException(nameof(operation)); @@ -50,6 +57,8 @@ public async Task ProcessAsync(AtomicOperationObject operati } var model = (TResource) _deserializer.CreateResourceFromObject(operation.SingleData); + ValidateModelState(model); + var result = await _service.UpdateAsync(model.Id, model, cancellationToken); ResourceObject data = null; @@ -76,10 +85,10 @@ public class UpdateProcessor : UpdateProcessor, IUpdateProcessor where TResource : class, IIdentifiable { - public UpdateProcessor(IUpdateService service, IJsonApiDeserializer deserializer, + public UpdateProcessor(IUpdateService service, IJsonApiOptions options, + IObjectModelValidator validator, IJsonApiDeserializer deserializer, IResourceObjectBuilder resourceObjectBuilder, IResourceContextProvider resourceContextProvider) - : base(service, deserializer, resourceObjectBuilder, - resourceContextProvider) + : base(service, options, validator, deserializer, resourceObjectBuilder, resourceContextProvider) { } } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs index d6a3459aa9..4f1bd83fc6 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs @@ -2,6 +2,7 @@ using System.Linq; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Microsoft.Extensions.DependencyInjection; @@ -30,14 +31,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.OperationCode == AtomicOperationCode.Update) { var targetedFields = _serviceProvider.GetRequiredService(); return IsFieldTargeted(entry, targetedFields); @@ -51,6 +52,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/BaseJsonApiAtomicOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs index 39ee85feee..8b91399159 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs @@ -99,11 +99,6 @@ public virtual async Task PostOperationsAsync([FromBody] AtomicOp return new StatusCodeResult(422); } - if (_options.ValidateModelState) - { - // TODO: @OPS: Add ModelState validation. - } - var results = await _processor.ProcessAsync(document.Operations, cancellationToken); if (results.Any(result => result.Data != null)) diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs index 3ea888e4ff..52450df3fe 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs @@ -1,5 +1,6 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.Middleware { @@ -57,5 +58,10 @@ 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 operation currently being processed. + /// + AtomicOperationCode? OperationCode { get; } } } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs index 5080c5f9e2..3263137fb3 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs @@ -1,5 +1,6 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.Middleware { @@ -29,5 +30,8 @@ public sealed class JsonApiRequest : IJsonApiRequest /// public bool IsReadOnly { get; set; } + + /// + public AtomicOperationCode? OperationCode { get; set; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationStartup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationStartup.cs new file mode 100644 index 0000000000..1dfd367dd5 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationStartup.cs @@ -0,0 +1,19 @@ +using JsonApiDotNetCore.Configuration; +using Microsoft.Extensions.Configuration; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ModelStateValidation +{ + public sealed class AtomicModelStateValidationStartup : TestableStartup + { + public AtomicModelStateValidationStartup(IConfiguration configuration) : base(configuration) + { + } + + protected override void SetJsonApiOptions(JsonApiOptions options) + { + base.SetJsonApiOptions(options); + + options.ValidateModelState = true; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs new file mode 100644 index 0000000000..69211263ab --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs @@ -0,0 +1,433 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ModelStateValidation +{ + public sealed class AtomicModelStateValidationTests + : IClassFixture> + { + private readonly IntegrationTestContext _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicModelStateValidationTests(IntegrationTestContext testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + }); + } + + [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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs index dd8f4dfc1d..5cacdaf2ff 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs @@ -8,13 +8,16 @@ 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; } + public decimal? LengthInSeconds { get; set; } [Attr] public string Genre { get; set; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs index a1c7c9d5e2..159a7691b4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs @@ -28,12 +28,12 @@ public static void BeCloseTo(this ObjectAssertions source, DateTimeOffset? expec /// /// Used to assert on a column, whose value is returned as in json:api response body. /// - public static void BeApproximately(this ObjectAssertions source, decimal expected, decimal precision = 0.00000000001M, string because = "", + public static void BeApproximately(this ObjectAssertions source, decimal? expected, decimal precision = 0.00000000001M, string because = "", params object[] becauseArgs) { // We lose a little bit of precision on roundtrip through PostgreSQL database. - var value = (decimal) (double) source.Subject; + var value = (decimal?) (double) source.Subject; value.Should().BeApproximately(expected, precision, because, becauseArgs); } } From 0aafb84ecd568f4d7241b22604bf9aab562182c7 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 2 Dec 2020 13:24:19 +0100 Subject: [PATCH 025/123] Fixes in handling add-to and remove-from to-many relationships --- .../AtomicOperationsProcessor.cs | 3 +- .../Repositories/DbContextExtensions.cs | 4 +- .../Mixed/AtomicLocalIdTests.cs | 115 +++++++++++------- 3 files changed, 74 insertions(+), 48 deletions(-) diff --git a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs index 7b1a6d8d17..e6a86a6f2d 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs @@ -57,8 +57,7 @@ public async Task> ProcessAsync(IList /// Detaches all entities from the change tracker. /// @@ -57,8 +56,7 @@ public static void ResetChangeTracker(this DbContext dbContext) { if (dbContext == null) throw new ArgumentNullException(nameof(dbContext)); - List entriesWithChanges = dbContext.ChangeTracker.Entries().Where(entry => - entry.State != EntityState.Detached).ToList(); + List entriesWithChanges = dbContext.ChangeTracker.Entries().ToList(); foreach (EntityEntry entry in entriesWithChanges) { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs index 733b5c6c22..e900624ace 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs @@ -1294,7 +1294,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_add_to_ManyToMany_relationship_using_local_ID() { // Arrange - var existingTrack = _fakers.MusicTrack.Generate(); + var existingTracks = _fakers.MusicTrack.Generate(2); var newPlaylistName = _fakers.Playlist.Generate().Name; var newTrackTitle = _fakers.MusicTrack.Generate().Title; @@ -1304,7 +1304,7 @@ public async Task Can_add_to_ManyToMany_relationship_using_local_ID() await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.MusicTracks.Add(existingTrack); + dbContext.MusicTracks.AddRange(existingTracks); await dbContext.SaveChangesAsync(); }); @@ -1332,7 +1332,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "musicTracks", - id = existingTrack.StringId + id = existingTracks[0].StringId } } } @@ -1369,6 +1369,24 @@ await _testContext.RunOnDatabaseAsync(async dbContext => lid = trackLocalId } } + }, + new + { + op = "add", + @ref = new + { + type = "playlists", + lid = playlistLocalId, + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + id = existingTracks[1].StringId + } + } } } }; @@ -1381,7 +1399,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(3); + responseDocument.Results.Should().HaveCount(4); responseDocument.Results[0].SingleData.Should().NotBeNull(); responseDocument.Results[0].SingleData.Type.Should().Be("playlists"); @@ -1395,6 +1413,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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); @@ -1407,8 +1427,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => playlistInDatabase.Name.Should().Be(newPlaylistName); - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(2); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTrack.Id); + 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); }); } @@ -1543,17 +1564,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_remove_from_ManyToMany_relationship_using_local_ID() { // Arrange - var existingTrack = _fakers.MusicTrack.Generate(); + var existingPlaylist = _fakers.Playlist.Generate(); + existingPlaylist.PlaylistMusicTracks = new[] + { + new PlaylistMusicTrack + { + MusicTrack = _fakers.MusicTrack.Generate() + }, + new PlaylistMusicTrack + { + MusicTrack = _fakers.MusicTrack.Generate() + } + }; - 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.Add(existingTrack); + dbContext.Playlists.Add(existingPlaylist); await dbContext.SaveChangesAsync(); }); @@ -1577,32 +1607,36 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { op = "add", - data = new + @ref = new { type = "playlists", - lid = playlistLocalId, - attributes = new + id = existingPlaylist.StringId, + relationship = "tracks" + }, + data = new[] + { + new { - name = newPlaylistName - }, - relationships = new + type = "musicTracks", + lid = trackLocalId + } + } + }, + new + { + op = "remove", + @ref = new + { + type = "playlists", + id = existingPlaylist.StringId, + relationship = "tracks" + }, + data = new[] + { + new { - tracks = new - { - data = new object[] - { - new - { - type = "musicTracks", - id = existingTrack.StringId - }, - new - { - type = "musicTracks", - lid = trackLocalId - } - } - } + type = "musicTracks", + id = existingPlaylist.PlaylistMusicTracks[1].MusicTrack.StringId } } }, @@ -1612,7 +1646,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => @ref = new { type = "playlists", - lid = playlistLocalId, + id = existingPlaylist.StringId, relationship = "tracks" }, data = new[] @@ -1635,33 +1669,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(3); + 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("playlists"); - responseDocument.Results[1].SingleData.Lid.Should().BeNull(); - responseDocument.Results[1].SingleData.Attributes["name"].Should().Be(newPlaylistName); + responseDocument.Results[1].Data.Should().BeNull(); responseDocument.Results[2].Data.Should().BeNull(); - var newPlaylistId = long.Parse(responseDocument.Results[1].SingleData.Id); + 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 == newPlaylistId); - - playlistInDatabase.Name.Should().Be(newPlaylistName); + .FirstAsync(playlist => playlist.Id == existingPlaylist.Id); playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); - playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(existingTrack.Id); + playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(existingPlaylist.PlaylistMusicTracks[0].MusicTrack.Id); }); } From 4d4e5d7589e6d9915d337ecee9ba5d637d3a49de Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 2 Dec 2020 17:33:32 +0100 Subject: [PATCH 026/123] Run model state validation for all operations up-front --- .../AtomicOperationsProcessor.cs | 100 +++++++++++++++--- .../BaseAtomicOperationProcessor.cs | 35 ------ .../Processors/CreateProcessor.cs | 20 ++-- .../Processors/UpdateProcessor.cs | 13 +-- .../Controllers/ModelStateViolation.cs | 21 ++++ .../Errors/InvalidModelStateException.cs | 57 ++++++---- .../AtomicModelStateValidationTests.cs | 62 +++++++++++ 7 files changed, 213 insertions(+), 95 deletions(-) delete mode 100644 src/JsonApiDotNetCore/AtomicOperations/Processors/BaseAtomicOperationProcessor.cs create mode 100644 src/JsonApiDotNetCore/Controllers/ModelStateViolation.cs diff --git a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs index e6a86a6f2d..9b35f287cd 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs @@ -5,11 +5,15 @@ using System.Threading; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Microsoft.EntityFrameworkCore; namespace JsonApiDotNetCore.AtomicOperations @@ -18,23 +22,30 @@ namespace JsonApiDotNetCore.AtomicOperations public class AtomicOperationsProcessor : IAtomicOperationsProcessor { private readonly IAtomicOperationProcessorResolver _resolver; + private readonly IJsonApiOptions _options; private readonly ILocalIdTracker _localIdTracker; private readonly DbContext _dbContext; private readonly IJsonApiRequest _request; private readonly ITargetedFields _targetedFields; private readonly IResourceContextProvider _resourceContextProvider; + private readonly IObjectModelValidator _validator; + private readonly IJsonApiDeserializer _deserializer; - public AtomicOperationsProcessor(IAtomicOperationProcessorResolver resolver, ILocalIdTracker localIdTracker, - IJsonApiRequest request, ITargetedFields targetedFields, IResourceContextProvider resourceContextProvider, - IEnumerable dbContextResolvers) + public AtomicOperationsProcessor(IAtomicOperationProcessorResolver resolver, IJsonApiOptions options, + ILocalIdTracker localIdTracker, IJsonApiRequest request, ITargetedFields targetedFields, + IResourceContextProvider resourceContextProvider, IEnumerable dbContextResolvers, + IObjectModelValidator validator, IJsonApiDeserializer deserializer) { if (dbContextResolvers == null) throw new ArgumentNullException(nameof(dbContextResolvers)); _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); + _options = options ?? throw new ArgumentNullException(nameof(options)); _localIdTracker = localIdTracker ?? throw new ArgumentNullException(nameof(localIdTracker)); _request = request ?? throw new ArgumentNullException(nameof(request)); _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); + _validator = validator ?? throw new ArgumentNullException(nameof(validator)); + _deserializer = deserializer ?? throw new ArgumentNullException(nameof(deserializer)); var resolvers = dbContextResolvers.ToArray(); if (resolvers.Length != 1) @@ -50,6 +61,11 @@ public async Task> ProcessAsync(IList(); await using var transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken); @@ -98,6 +114,45 @@ public async Task> ProcessAsync(IList operations) + { + var violations = new List(); + + int index = 0; + foreach (var operation in operations) + { + if (operation.Ref?.Relationship == null && operation.SingleData != null) + { + PrepareForOperation(operation); + + var validationContext = new ActionContext(); + + var model = _deserializer.CreateResourceFromObject(operation.SingleData); + _validator.Validate(validationContext, null, string.Empty, model); + + 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, model.GetType(), error); + violations.Add(violation); + } + } + } + } + + index++; + } + + if (violations.Any()) + { + var namingStrategy = _options.SerializerContractResolver.NamingStrategy; + throw new InvalidModelStateException(violations, _options.IncludeExceptionStackTraceInErrors, namingStrategy); + } + } + private async Task ProcessOperation(AtomicOperationObject operation, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -167,38 +222,49 @@ private async Task ProcessOperation(AtomicOperationObject op }); } + PrepareForOperation(operation); + + var processor = _resolver.ResolveProcessor(operation); + return await processor.ProcessAsync(operation, cancellationToken); + } + + private void PrepareForOperation(AtomicOperationObject operation) + { _targetedFields.Attributes.Clear(); _targetedFields.Relationships.Clear(); + var resourceName = operation.GetResourceTypeName(); + var primaryResourceContext = _resourceContextProvider.GetResourceContext(resourceName); + + ((JsonApiRequest) _request).OperationCode = operation.Code; + ((JsonApiRequest)_request).PrimaryResource = primaryResourceContext; + if (operation.Ref?.Relationship != null) { - var primaryResourceContext = _resourceContextProvider.GetResourceContext(operation.Ref.Type); - var requestRelationship = primaryResourceContext.Relationships.SingleOrDefault(relationship => relationship.PublicName == operation.Ref.Relationship); - - if (requestRelationship == null) + var relationship = primaryResourceContext.Relationships.SingleOrDefault(relationship => relationship.PublicName == operation.Ref.Relationship); + if (relationship == null) { throw new InvalidOperationException("TODO: Relationship does not exist."); } - ((JsonApiRequest)_request).PrimaryResource = primaryResourceContext; + var secondaryResource = _resourceContextProvider.GetResourceContext(relationship.RightType); + if (secondaryResource == null) + { + throw new InvalidOperationException("TODO: Secondary resource does not exist."); + } + ((JsonApiRequest)_request).PrimaryId = operation.Ref.Id; - ((JsonApiRequest)_request).Relationship = requestRelationship; - ((JsonApiRequest)_request).SecondaryResource = _resourceContextProvider.GetResourceContext(requestRelationship.RightType); + ((JsonApiRequest)_request).Relationship = relationship; + ((JsonApiRequest)_request).SecondaryResource = secondaryResource; - _targetedFields.Relationships.Add(_request.Relationship); + _targetedFields.Relationships.Add(relationship); } else { - ((JsonApiRequest)_request).PrimaryResource = resourceContext; ((JsonApiRequest)_request).PrimaryId = null; ((JsonApiRequest)_request).Relationship = null; ((JsonApiRequest)_request).SecondaryResource = null; } - - ((JsonApiRequest) _request).OperationCode = operation.Code; - - var processor = _resolver.ResolveProcessor(operation); - return await processor.ProcessAsync(operation, cancellationToken); } private void ReplaceLocalIdsInOperationObject(AtomicOperationObject operation, bool isResourceAdd) diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/BaseAtomicOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/BaseAtomicOperationProcessor.cs deleted file mode 100644 index 1b3a1e863e..0000000000 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/BaseAtomicOperationProcessor.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; - -namespace JsonApiDotNetCore.AtomicOperations.Processors -{ - public abstract class BaseAtomicOperationProcessor - { - private readonly IJsonApiOptions _options; - private readonly IObjectModelValidator _validator; - - protected BaseAtomicOperationProcessor(IJsonApiOptions options, IObjectModelValidator validator) - { - _options = options ?? throw new ArgumentNullException(nameof(options)); - _validator = validator ?? throw new ArgumentNullException(nameof(validator)); - } - - protected void ValidateModelState(TResource model) - { - if (_options.ValidateModelState) - { - var actionContext = new ActionContext(); - _validator.Validate(actionContext, null, string.Empty, model); - - if (!actionContext.ModelState.IsValid) - { - var namingStrategy = _options.SerializerContractResolver.NamingStrategy; - throw new InvalidModelStateException(actionContext.ModelState, typeof(TResource), _options.IncludeExceptionStackTraceInErrors, namingStrategy); - } - } - } - } -} diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs index 53a35e7cbf..d60d94efe8 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs @@ -7,7 +7,6 @@ using JsonApiDotNetCore.Serialization.Building; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCore.Services; -using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; namespace JsonApiDotNetCore.AtomicOperations.Processors { @@ -16,7 +15,7 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors /// /// The resource type. /// The resource identifier type. - public class CreateProcessor : BaseAtomicOperationProcessor, ICreateProcessor + public class CreateProcessor : ICreateProcessor where TResource : class, IIdentifiable { private readonly ICreateService _service; @@ -25,10 +24,9 @@ public class CreateProcessor : BaseAtomicOperationProcessor, ICr private readonly IResourceObjectBuilder _resourceObjectBuilder; private readonly IResourceContextProvider _resourceContextProvider; - public CreateProcessor(ICreateService service, IJsonApiOptions options, - IObjectModelValidator validator, ILocalIdTracker localIdTracker, IJsonApiDeserializer deserializer, - IResourceObjectBuilder resourceObjectBuilder, IResourceContextProvider resourceContextProvider) - : base(options, validator) + public CreateProcessor(ICreateService service, ILocalIdTracker localIdTracker, + IJsonApiDeserializer deserializer, IResourceObjectBuilder resourceObjectBuilder, + IResourceContextProvider resourceContextProvider) { _service = service ?? throw new ArgumentNullException(nameof(service)); _localIdTracker = localIdTracker ?? throw new ArgumentNullException(nameof(localIdTracker)); @@ -44,7 +42,6 @@ public async Task ProcessAsync(AtomicOperationObject operati if (operation == null) throw new ArgumentNullException(nameof(operation)); var model = (TResource) _deserializer.CreateResourceFromObject(operation.SingleData); - ValidateModelState(model); var newResource = await _service.CreateAsync(model, cancellationToken); @@ -78,11 +75,10 @@ public class CreateProcessor : CreateProcessor, ICreateProcessor where TResource : class, IIdentifiable { - public CreateProcessor(ICreateService service, IJsonApiOptions options, - IObjectModelValidator validator, ILocalIdTracker localIdTracker, IJsonApiDeserializer deserializer, - IResourceObjectBuilder resourceObjectBuilder, IResourceContextProvider resourceContextProvider) - : base(service, options, validator, localIdTracker, deserializer, resourceObjectBuilder, - resourceContextProvider) + public CreateProcessor(ICreateService service, ILocalIdTracker localIdTracker, + IJsonApiDeserializer deserializer, IResourceObjectBuilder resourceObjectBuilder, + IResourceContextProvider resourceContextProvider) + : base(service, localIdTracker, deserializer, resourceObjectBuilder, resourceContextProvider) { } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs index 7b2b41fd21..559071dbe7 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs @@ -9,7 +9,6 @@ using JsonApiDotNetCore.Serialization.Building; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCore.Services; -using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; namespace JsonApiDotNetCore.AtomicOperations.Processors { @@ -19,7 +18,7 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors /// /// The resource type. /// The resource identifier type. - public class UpdateProcessor : BaseAtomicOperationProcessor, IUpdateProcessor + public class UpdateProcessor : IUpdateProcessor where TResource : class, IIdentifiable { private readonly IUpdateService _service; @@ -27,10 +26,8 @@ public class UpdateProcessor : BaseAtomicOperationProcessor, IUp private readonly IResourceObjectBuilder _resourceObjectBuilder; private readonly IResourceContextProvider _resourceContextProvider; - public UpdateProcessor(IUpdateService service, IJsonApiOptions options, - IObjectModelValidator validator, IJsonApiDeserializer deserializer, + public UpdateProcessor(IUpdateService service, IJsonApiDeserializer deserializer, IResourceObjectBuilder resourceObjectBuilder, IResourceContextProvider resourceContextProvider) - : base(options, validator) { _service = service ?? throw new ArgumentNullException(nameof(service)); _deserializer = deserializer ?? throw new ArgumentNullException(nameof(deserializer)); @@ -57,7 +54,6 @@ public async Task ProcessAsync(AtomicOperationObject operati } var model = (TResource) _deserializer.CreateResourceFromObject(operation.SingleData); - ValidateModelState(model); var result = await _service.UpdateAsync(model.Id, model, cancellationToken); @@ -85,10 +81,9 @@ public class UpdateProcessor : UpdateProcessor, IUpdateProcessor where TResource : class, IIdentifiable { - public UpdateProcessor(IUpdateService service, IJsonApiOptions options, - IObjectModelValidator validator, IJsonApiDeserializer deserializer, + public UpdateProcessor(IUpdateService service, IJsonApiDeserializer deserializer, IResourceObjectBuilder resourceObjectBuilder, IResourceContextProvider resourceContextProvider) - : base(service, options, validator, deserializer, resourceObjectBuilder, resourceContextProvider) + : base(service, deserializer, resourceObjectBuilder, resourceContextProvider) { } } diff --git a/src/JsonApiDotNetCore/Controllers/ModelStateViolation.cs b/src/JsonApiDotNetCore/Controllers/ModelStateViolation.cs new file mode 100644 index 0000000000..347a7687f8 --- /dev/null +++ b/src/JsonApiDotNetCore/Controllers/ModelStateViolation.cs @@ -0,0 +1,21 @@ +using System; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace JsonApiDotNetCore.Controllers +{ + 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/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs index 69211263ab..700df9b745 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs @@ -429,5 +429,67 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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 = "/api/v1/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"); + } } } From e5237df19b3ab2de2775642ee45baaad29b72171 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 15 Dec 2020 13:25:47 +0100 Subject: [PATCH 027/123] Fix broken test after rebase --- .../AtomicOperations/QueryStrings/AtomicQueryStringTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs index b6164f70ab..0e71616210 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs @@ -247,7 +247,7 @@ public async Task Cannot_use_sparse_fieldset_on_operations_endpoint() } }; - var route = "/api/v1/operations?fields=id"; + var route = "/api/v1/operations?fields[recordCompanies]=id"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -258,8 +258,8 @@ public async Task Cannot_use_sparse_fieldset_on_operations_endpoint() 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' cannot be used at this endpoint."); - responseDocument.Errors[0].Source.Parameter.Should().Be("fields"); + 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] From 56b8a845d9e5cbaa1f4bc972225c827897254c6c Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 15 Dec 2020 14:52:01 +0100 Subject: [PATCH 028/123] Added tests for delete resource --- .../AtomicOperationsProcessor.cs | 41 +- .../Processors/DeleteProcessor.cs | 11 +- .../Serialization/BaseDeserializer.cs | 4 +- .../Serialization/IJsonApiDeserializer.cs | 11 +- .../Serialization/JsonApiReader.cs | 32 +- .../JsonApiSerializationException.cs | 5 +- .../Serialization/RequestDeserializer.cs | 60 ++- .../Deleting/AtomicDeleteResourceTests.cs | 457 +++++++++++++++++- .../AtomicOperations/Lyric.cs | 17 + .../Mixed/MixedOperationsTests.cs | 4 + .../AtomicOperations/MusicTrack.cs | 3 + .../AtomicOperations/OperationsDbContext.cs | 7 + .../AtomicOperations/OperationsFakers.cs | 7 + .../Updating/AtomicUpdateResourceTests.cs | 1 - 14 files changed, 602 insertions(+), 58 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Lyric.cs diff --git a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs index 9b35f287cd..35c237cd92 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs @@ -193,14 +193,7 @@ private async Task ProcessOperation(AtomicOperationObject op if (operation.Code == AtomicOperationCode.Remove) { - resourceName = operation.Ref?.Type; - if (resourceName == null) - { - throw new JsonApiException(new Error(HttpStatusCode.BadRequest) - { - Title = "The ref.type element is required." - }); - } + resourceName = operation.Ref.Type; } bool isResourceAdd = operation.Code == AtomicOperationCode.Add && operation.Ref == null; @@ -239,25 +232,29 @@ private void PrepareForOperation(AtomicOperationObject operation) ((JsonApiRequest) _request).OperationCode = operation.Code; ((JsonApiRequest)_request).PrimaryResource = primaryResourceContext; - if (operation.Ref?.Relationship != null) + if (operation.Ref != null) { - var relationship = primaryResourceContext.Relationships.SingleOrDefault(relationship => relationship.PublicName == operation.Ref.Relationship); - if (relationship == null) - { - throw new InvalidOperationException("TODO: Relationship does not exist."); - } + ((JsonApiRequest)_request).PrimaryId = operation.Ref.Id; - var secondaryResource = _resourceContextProvider.GetResourceContext(relationship.RightType); - if (secondaryResource == null) + if (operation.Ref?.Relationship != null) { - throw new InvalidOperationException("TODO: Secondary resource does not exist."); - } + var relationship = primaryResourceContext.Relationships.SingleOrDefault(relationship => relationship.PublicName == operation.Ref.Relationship); + if (relationship == null) + { + throw new InvalidOperationException("TODO: Relationship does not exist."); + } - ((JsonApiRequest)_request).PrimaryId = operation.Ref.Id; - ((JsonApiRequest)_request).Relationship = relationship; - ((JsonApiRequest)_request).SecondaryResource = secondaryResource; + var secondaryResource = _resourceContextProvider.GetResourceContext(relationship.RightType); + if (secondaryResource == null) + { + throw new InvalidOperationException("TODO: Secondary resource does not exist."); + } + + ((JsonApiRequest)_request).Relationship = relationship; + ((JsonApiRequest)_request).SecondaryResource = secondaryResource; - _targetedFields.Relationships.Add(relationship); + _targetedFields.Relationships.Add(relationship); + } } else { diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs index 17e409b5b6..085873377b 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs @@ -25,16 +25,7 @@ public async Task ProcessAsync(AtomicOperationObject operati { if (operation == null) throw new ArgumentNullException(nameof(operation)); - var stringId = operation.Ref.Id; - if (stringId == null) - { - throw new JsonApiException(new Error(HttpStatusCode.BadRequest) - { - Title = "The ref.id element is required for remove operations." - }); - } - - var id = (TId) TypeHelper.ConvertType(stringId, typeof(TId)); + var id = (TId) TypeHelper.ConvertType(operation.Ref.Id, typeof(TId)); await _service.DeleteAsync(id, cancellationToken); return new AtomicResultObject(); diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index 094321322d..ee0180e302 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -173,13 +173,13 @@ protected IIdentifiable ParseResourceObject(ResourceObject data, bool allowLocal return resource; } - private ResourceContext GetExistingResourceContext(string publicName) + protected ResourceContext GetExistingResourceContext(string publicName, int? atomicOperationIndex = null) { 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; diff --git a/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs index cc4513c5ce..a1bee9b7d8 100644 --- a/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs @@ -9,21 +9,12 @@ 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 DeserializeDocument(string body); - /// - /// Deserializes JSON into a and constructs entities - /// from . - /// - /// The JSON to be deserialized - /// The operations document constructed from the content - object DeserializeOperationsDocument(string body); - /// /// Creates an instance of the referenced type in /// and sets its attributes and relationships diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index d7427500d7..07fa37ed47 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs @@ -50,12 +50,6 @@ public async Task ReadAsync(InputFormatterContext context) string url = context.HttpContext.Request.GetEncodedUrl(); _traceWriter.LogMessage(() => $"Received request at '{url}' with body: <<{body}>>"); - if (_request.Kind == EndpointKind.AtomicOperations) - { - var operations = _deserializer.DeserializeOperationsDocument(body); - return await InputFormatterResult.SuccessAsync(operations); - } - object model = null; if (!string.IsNullOrWhiteSpace(body)) { @@ -65,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) { @@ -73,7 +67,7 @@ public async Task ReadAsync(InputFormatterContext context) } } - if (RequiresRequestBody(context.HttpContext.Request.Method)) + if (_request.Kind != EndpointKind.AtomicOperations && RequiresRequestBody(context.HttpContext.Request.Method)) { ValidateRequestBody(model, body, context.HttpContext.Request); } @@ -87,6 +81,28 @@ 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); + } + + 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) 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/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index 3ab23b40be..e0dacf5236 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -44,7 +44,12 @@ public object DeserializeDocument(string body) { _targetedFields.Relationships.Add(_request.Relationship); } - + + if (_request.Kind == EndpointKind.AtomicOperations) + { + return DeserializeOperationsDocument(body); + } + var instance = DeserializeBody(body); AssertResourceIdIsNotTargeted(); @@ -52,7 +57,7 @@ public object DeserializeDocument(string body) return instance; } - public object DeserializeOperationsDocument(string body) + private object DeserializeOperationsDocument(string body) { JToken bodyToken = LoadJToken(body); var document = bodyToken.ToObject(); @@ -65,9 +70,60 @@ public object DeserializeOperationsDocument(string body) }); } + int index = 0; + foreach (var operation in document.Operations) + { + ValidateOperation(operation, index); + index++; + } + return document; } + private void ValidateOperation(AtomicOperationObject operation, int index) + { + if (operation.Href != null) + { + throw new JsonApiSerializationException("Usage of the 'href' element is not supported.", null, + atomicOperationIndex: index); + } + + if (operation.Code == AtomicOperationCode.Remove) + { + if (operation.Ref == null) + { + throw new JsonApiSerializationException("The 'ref' element is required.", null, + atomicOperationIndex: index); + } + + if (operation.Ref.Type == null) + { + throw new JsonApiSerializationException("The 'ref.type' element is required.", null, + atomicOperationIndex: index); + } + + var resourceContext = GetExistingResourceContext(operation.Ref.Type, index); + + if (operation.Ref.Id == null && operation.Ref.Lid == null) + { + throw new JsonApiSerializationException("The 'ref.id' or 'ref.lid' element is required.", null, + atomicOperationIndex: index); + } + + if (operation.Ref.Id != null) + { + try + { + TypeHelper.ConvertType(operation.Ref.Id, resourceContext.IdentityType); + } + catch (FormatException exception) + { + throw new JsonApiSerializationException(null, exception.Message, null, index); + } + } + } + } + public IIdentifiable CreateResourceFromObject(ResourceObject data) { if (data == null) throw new ArgumentNullException(nameof(data)); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs index 3620536b33..5096d22469 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Threading.Tasks; using FluentAssertions; @@ -29,7 +31,7 @@ public AtomicDeleteResourceTests(IntegrationTestContext } [Fact] - public async Task Can_delete_resources() + public async Task Can_delete_existing_resources() { // Arrange const int elementCount = 5; @@ -127,5 +129,456 @@ await _testContext.RunOnDatabaseAsync(async dbContext => tracksInDatabase.Should().BeEmpty(); }); } + + [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 = "/api/v1/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_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 = "/api/v1/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_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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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]"); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Lyric.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Lyric.cs new file mode 100644 index 0000000000..f60193662c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Lyric.cs @@ -0,0 +1,17 @@ +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 MusicTrack Track { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs index 592437fb31..c9d50aeea1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs @@ -109,5 +109,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => tracksInDatabase.Should().BeEmpty(); }); } + + // TODO: Cannot_process_operations_for_missing_request_body + // TODO: Cannot_process_operations_for_broken_JSON_request_body + // TODO: Cannot_process_operations_for_unknown_operation_code } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs index 5cacdaf2ff..8639702c78 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs @@ -25,6 +25,9 @@ public sealed class MusicTrack : Identifiable [Attr] public DateTimeOffset ReleasedAt { get; set; } + [HasOne] + public Lyric Lyric { get; set;} + [HasOne] public RecordCompany OwnedBy { get; set; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs index 1aba049a0e..0bd93e403b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs @@ -6,6 +6,8 @@ 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 Performers { get; set; } public DbSet RecordCompanies { get; set; } @@ -18,6 +20,11 @@ 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 index 2a335cf6a4..ec44a47fb8 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsFakers.cs @@ -18,6 +18,12 @@ internal sealed class OperationsFakers : FakerContainer .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> _lazyPerformerFaker = new Lazy>(() => new Faker() .UseSeed(GetFakerSeed()) @@ -32,6 +38,7 @@ internal sealed class OperationsFakers : FakerContainer public Faker Playlist => _lazyPlaylistFaker.Value; public Faker MusicTrack => _lazyMusicTrackFaker.Value; + public Faker Lyric => _lazyLyricFaker.Value; public Faker Performer => _lazyPerformerFaker.Value; public Faker RecordCompany => _lazyRecordCompanyFaker.Value; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/AtomicUpdateResourceTests.cs index f9d598e47e..ab7370e7c4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/AtomicUpdateResourceTests.cs @@ -3,7 +3,6 @@ using System.Net; using System.Threading.Tasks; using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.EntityFrameworkCore; From cd51071bf30084997eb27eaa2bf33e5acf8308e0 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 16 Dec 2020 15:27:21 +0100 Subject: [PATCH 029/123] File rename --- .../Acceptance/InjectableResourceTests.cs | 3 ++- .../Updating/{ => Resources}/AtomicUpdateResourceTests.cs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/{ => Resources}/AtomicUpdateResourceTests.cs (99%) diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs index 6388be509f..2448ec7c5d 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs @@ -103,6 +103,7 @@ public async Task Can_Get_Passports_With_Filter() { // Arrange await _context.ClearTableAsync(); + await _context.ClearTableAsync(); var passports = _passportFaker.Generate(3); foreach (var passport in passports) @@ -114,7 +115,7 @@ public async Task Can_Get_Passports_With_Filter() } passports[2].SocialSecurityNumber = 12345; - passports[2].Person.FirstName= "Joe"; + passports[2].Person.FirstName = "Joe"; _context.Passports.AddRange(passports); await _context.SaveChangesAsync(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/AtomicUpdateResourceTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs index ab7370e7c4..0926792675 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs @@ -9,7 +9,7 @@ using Microsoft.Extensions.DependencyInjection; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Resources { public sealed class AtomicUpdateResourceTests : IClassFixture, OperationsDbContext>> From 2efc6f94b7a558706052927ffe1f8c4ee20a230a Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 16 Dec 2020 15:39:42 +0100 Subject: [PATCH 030/123] added tests for Remove from to-many --- .../RemoveFromRelationshipProcessor.cs | 5 - .../Serialization/RequestDeserializer.cs | 58 ++ .../Mixed/MixedOperationsTests.cs | 1 + ...AtomicRemoveFromToManyRelationshipTests.cs | 745 ++++++++++++++++++ 4 files changed, 804 insertions(+), 5 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs index 1c2921d22a..ce2471ea89 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs @@ -34,11 +34,6 @@ public async Task ProcessAsync(AtomicOperationObject operati { if (operation == null) throw new ArgumentNullException(nameof(operation)); - if (operation.ManyData == null) - { - throw new InvalidOperationException("TODO: Expected data array. Can we ever get here?"); - } - var primaryId = GetPrimaryId(operation.Ref.Id); var secondaryResourceIds = GetSecondaryResourceIds(operation); diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index e0dacf5236..244d7814a9 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -121,6 +121,64 @@ private void ValidateOperation(AtomicOperationObject operation, int index) throw new JsonApiSerializationException(null, exception.Message, null, index); } } + + if (operation.Ref.Relationship != null) + { + var relationship = resourceContext.Relationships.FirstOrDefault(r => r.PublicName == operation.Ref.Relationship); + if (relationship == null) + { + throw new JsonApiSerializationException( + "The referenced relationship does not exist.", + $"Resource of type '{operation.Ref.Type}' does not contain a relationship named '{operation.Ref.Relationship}'.", + atomicOperationIndex: index); + } + + if (relationship is HasOneAttribute) + { + throw new JsonApiSerializationException( + "Only to-many relationships can be targeted in 'remove' operations.", + $"Relationship '{operation.Ref.Relationship}' must be a to-many relationship.", + atomicOperationIndex: index); + } + + if (operation.Data == null) + { + throw new JsonApiSerializationException( + "Expected data[] element for to-many relationship.", + $"Expected data[] element for '{relationship.PublicName}' relationship.", + atomicOperationIndex: index); + } + + if (!operation.IsManyData) + { + throw new Exception("TODO: data is not an array."); + } + + foreach (var resourceObject in operation.ManyData) + { + if (resourceObject.Type == null) + { + throw new JsonApiSerializationException("The 'data[].type' element is required.", null, + atomicOperationIndex: index); + } + + if (resourceObject.Id == null && resourceObject.Lid == null) + { + throw new JsonApiSerializationException("The 'data[].id' or 'data[].lid' element is required.", null, + atomicOperationIndex: index); + } + + var rightResourceContext = GetExistingResourceContext(resourceObject.Type, index); + if (!rightResourceContext.ResourceType.IsAssignableFrom(relationship.RightType)) + { + var relationshipRightTypeName = ResourceContextProvider.GetResourceContext(relationship.RightType); + + throw new JsonApiSerializationException("Resource type mismatch between 'ref' and 'data' element.", + $@"Expected resource of type '{relationshipRightTypeName}' in 'data[].type', instead of '{rightResourceContext.PublicName}'.", + atomicOperationIndex: index); + } + } + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs index c9d50aeea1..acfdd83fc9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs @@ -110,6 +110,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + // TODO: Can_process_empty_operations_array // TODO: Cannot_process_operations_for_missing_request_body // TODO: Cannot_process_operations_for_broken_JSON_request_body // TODO: Cannot_process_operations_for_unknown_operation_code 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..73f9a54785 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs @@ -0,0 +1,745 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Relationships +{ + public sealed class AtomicRemoveFromToManyRelationshipTests + : IClassFixture, OperationsDbContext>> + { + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicRemoveFromToManyRelationshipTests( + IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + }); + } + + [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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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_missing_type_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + id = 99999999, + relationship = "tracks" + } + } + } + }; + + var route = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 + { + id = existingTrack.StringId, + type = "musicTracks", + relationship = "performers" + }, + data = (object)null + } + } + }; + + var route = "/api/v1/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 + { + id = 99999999, + type = "playlists", + relationship = "tracks" + }, + data = new[] + { + new + { + id = Guid.NewGuid().ToString() + } + } + } + } + }; + + var route = "/api/v1/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 + { + id = Guid.NewGuid().ToString(), + type = "musicTracks", + relationship = "performers" + }, + data = new[] + { + new + { + type = "doesNotExist", + id = 99999999 + } + } + } + } + }; + + var route = "/api/v1/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 + { + id = Guid.NewGuid().ToString(), + type = "musicTracks", + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers" + } + } + } + } + }; + + var route = "/api/v1/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 + { + id = existingCompany.StringId, + type = "recordCompanies", + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + id = trackIds[0].ToString() + }, + new + { + type = "musicTracks", + id = trackIds[1].ToString() + } + } + } + } + }; + + var route = "/api/v1/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 + { + id = existingTrack.StringId, + type = "musicTracks", + relationship = "performers" + }, + data = new[] + { + new + { + type = "playlists", + id = 88888888 + } + } + } + } + }; + + var route = "/api/v1/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' and 'data' 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 = "/api/v1/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); + }); + } + } +} From ce0cd4c7f36d4186a405d654d969740ad91993a9 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 17 Dec 2020 11:18:09 +0100 Subject: [PATCH 031/123] added tests for Add to to-many --- .../AtomicOperationsProcessor.cs | 27 +- .../Processors/AddToRelationshipProcessor.cs | 5 - .../Serialization/RequestDeserializer.cs | 6 +- .../AtomicAddToToManyRelationshipTests.cs | 738 ++++++++++++++++++ 4 files changed, 760 insertions(+), 16 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs diff --git a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs index 35c237cd92..8d0c468cb6 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs @@ -158,7 +158,16 @@ private async Task ProcessOperation(AtomicOperationObject op cancellationToken.ThrowIfCancellationRequested(); string resourceName = null; - if (operation.Code == AtomicOperationCode.Add || operation.Code == AtomicOperationCode.Update) + + if (operation.Code == AtomicOperationCode.Remove) + { + resourceName = operation.Ref.Type; + } + else if (operation.Code == AtomicOperationCode.Add && operation.Ref != null) + { + resourceName = operation.Ref.Type; + } + else { if (operation.SingleData != null) { @@ -171,7 +180,7 @@ private async Task ProcessOperation(AtomicOperationObject op }); } } - else if (operation.ManyData != null) + else if (operation.ManyData != null && operation.ManyData.Any()) { foreach (var resourceObject in operation.ManyData) { @@ -180,22 +189,20 @@ private async Task ProcessOperation(AtomicOperationObject op { throw new JsonApiException(new Error(HttpStatusCode.BadRequest) { - Title = "The data.type element is required." + Title = "The data[].type element is required." }); } + + // TODO: Verify all are of the same (or compatible) type. } } - else - { - throw new InvalidOperationException("TODO: Data is missing."); - } } - if (operation.Code == AtomicOperationCode.Remove) + if (resourceName == null) { - resourceName = operation.Ref.Type; + throw new InvalidOperationException("TODO: Failed to determine targeted resource."); } - + bool isResourceAdd = operation.Code == AtomicOperationCode.Add && operation.Ref == null; if (isResourceAdd && operation.SingleData?.Lid != null) diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs index 6c1db2c1af..3227d80365 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs @@ -34,11 +34,6 @@ public async Task ProcessAsync(AtomicOperationObject operati { if (operation == null) throw new ArgumentNullException(nameof(operation)); - if (operation.ManyData == null) - { - throw new InvalidOperationException("TODO: Expected data array. Can we ever get here?"); - } - var primaryId = GetPrimaryId(operation.Ref.Id); var secondaryResourceIds = GetSecondaryResourceIds(operation); diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index 244d7814a9..61e5dd9144 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Net; using System.Net.Http; +using Humanizer; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; @@ -95,7 +96,10 @@ private void ValidateOperation(AtomicOperationObject operation, int index) throw new JsonApiSerializationException("The 'ref' element is required.", null, atomicOperationIndex: index); } + } + if ((operation.Code == AtomicOperationCode.Remove || operation.Code == AtomicOperationCode.Add) && operation.Ref != null) + { if (operation.Ref.Type == null) { throw new JsonApiSerializationException("The 'ref.type' element is required.", null, @@ -136,7 +140,7 @@ private void ValidateOperation(AtomicOperationObject operation, int index) if (relationship is HasOneAttribute) { throw new JsonApiSerializationException( - "Only to-many relationships can be targeted in 'remove' operations.", + $"Only to-many relationships can be targeted in '{operation.Code.ToString().Camelize()}' operations.", $"Relationship '{operation.Ref.Relationship}' must be a to-many relationship.", atomicOperationIndex: index); } 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..bcbef21e8b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs @@ -0,0 +1,738 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Relationships +{ + public sealed class AtomicAddToToManyRelationshipTests + : IClassFixture, OperationsDbContext>> + { + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicAddToToManyRelationshipTests( + IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + }); + } + + [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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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_missing_type_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + id = 99999999, + relationship = "tracks" + } + } + } + }; + + var route = "/api/v1/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 = "/api/v1/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 = "/api/v1/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_relationship_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "performers", + id = 99999999, + relationship = "doesNotExist" + } + } + } + }; + + var route = "/api/v1/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 + { + id = existingTrack.StringId, + type = "musicTracks", + relationship = "performers" + }, + data = (object)null + } + } + }; + + var route = "/api/v1/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 + { + id = 99999999, + type = "playlists", + relationship = "tracks" + }, + data = new[] + { + new + { + id = Guid.NewGuid().ToString() + } + } + } + } + }; + + var route = "/api/v1/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 + { + id = Guid.NewGuid().ToString(), + type = "musicTracks", + relationship = "performers" + }, + data = new[] + { + new + { + type = "doesNotExist", + id = 99999999 + } + } + } + } + }; + + var route = "/api/v1/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 + { + id = Guid.NewGuid().ToString(), + type = "musicTracks", + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers" + } + } + } + } + }; + + var route = "/api/v1/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 + { + id = existingCompany.StringId, + type = "recordCompanies", + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + id = trackIds[0].ToString() + }, + new + { + type = "musicTracks", + id = trackIds[1].ToString() + } + } + } + } + }; + + var route = "/api/v1/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 + { + id = existingTrack.StringId, + type = "musicTracks", + relationship = "performers" + }, + data = new[] + { + new + { + type = "playlists", + id = 88888888 + } + } + } + } + }; + + var route = "/api/v1/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' and 'data' 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 = "/api/v1/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); + }); + } + } +} From 204b3209589eff739fdedc60f6a53cd85002a68c Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 17 Dec 2020 12:17:29 +0100 Subject: [PATCH 032/123] added tests for Replace to-many --- .../AtomicOperationsProcessor.cs | 6 +- .../Serialization/RequestDeserializer.cs | 52 +- .../AtomicAddToToManyRelationshipTests.cs | 2 +- ...AtomicRemoveFromToManyRelationshipTests.cs | 2 +- .../AtomicReplaceToManyRelationshipTests.cs | 738 ++++++++++++++++++ 5 files changed, 766 insertions(+), 34 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs diff --git a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs index 8d0c468cb6..66dc913aba 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs @@ -159,11 +159,7 @@ private async Task ProcessOperation(AtomicOperationObject op string resourceName = null; - if (operation.Code == AtomicOperationCode.Remove) - { - resourceName = operation.Ref.Type; - } - else if (operation.Code == AtomicOperationCode.Add && operation.Ref != null) + if (operation.Ref != null) { resourceName = operation.Ref.Type; } diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index 61e5dd9144..128ea12c8a 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -98,7 +98,7 @@ private void ValidateOperation(AtomicOperationObject operation, int index) } } - if ((operation.Code == AtomicOperationCode.Remove || operation.Code == AtomicOperationCode.Add) && operation.Ref != null) + if (operation.Ref != null) { if (operation.Ref.Type == null) { @@ -137,7 +137,7 @@ private void ValidateOperation(AtomicOperationObject operation, int index) atomicOperationIndex: index); } - if (relationship is HasOneAttribute) + if (operation.Code != AtomicOperationCode.Update && relationship is HasOneAttribute) { throw new JsonApiSerializationException( $"Only to-many relationships can be targeted in '{operation.Code.ToString().Camelize()}' operations.", @@ -145,7 +145,7 @@ private void ValidateOperation(AtomicOperationObject operation, int index) atomicOperationIndex: index); } - if (operation.Data == null) + if (relationship is HasManyAttribute && operation.ManyData == null) { throw new JsonApiSerializationException( "Expected data[] element for to-many relationship.", @@ -153,33 +153,31 @@ private void ValidateOperation(AtomicOperationObject operation, int index) atomicOperationIndex: index); } - if (!operation.IsManyData) + if (operation.ManyData != null) { - throw new Exception("TODO: data is not an array."); - } - - foreach (var resourceObject in operation.ManyData) - { - if (resourceObject.Type == null) - { - throw new JsonApiSerializationException("The 'data[].type' element is required.", null, - atomicOperationIndex: index); - } - - if (resourceObject.Id == null && resourceObject.Lid == null) - { - throw new JsonApiSerializationException("The 'data[].id' or 'data[].lid' element is required.", null, - atomicOperationIndex: index); - } - - var rightResourceContext = GetExistingResourceContext(resourceObject.Type, index); - if (!rightResourceContext.ResourceType.IsAssignableFrom(relationship.RightType)) + foreach (var resourceObject in operation.ManyData) { - var relationshipRightTypeName = ResourceContextProvider.GetResourceContext(relationship.RightType); + if (resourceObject.Type == null) + { + throw new JsonApiSerializationException("The 'data[].type' element is required.", null, + atomicOperationIndex: index); + } + + if (resourceObject.Id == null && resourceObject.Lid == null) + { + throw new JsonApiSerializationException("The 'data[].id' or 'data[].lid' element is required.", null, + atomicOperationIndex: index); + } + + var rightResourceContext = GetExistingResourceContext(resourceObject.Type, index); + if (!rightResourceContext.ResourceType.IsAssignableFrom(relationship.RightType)) + { + var relationshipRightTypeName = ResourceContextProvider.GetResourceContext(relationship.RightType); - throw new JsonApiSerializationException("Resource type mismatch between 'ref' and 'data' element.", - $@"Expected resource of type '{relationshipRightTypeName}' in 'data[].type', instead of '{rightResourceContext.PublicName}'.", - atomicOperationIndex: index); + throw new JsonApiSerializationException("Resource type mismatch between 'ref.relationship' and 'data[].type' element.", + $@"Expected resource of type '{relationshipRightTypeName}' in 'data[].type', instead of '{rightResourceContext.PublicName}'.", + atomicOperationIndex: index); + } } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs index bcbef21e8b..156b3d7d9e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs @@ -678,7 +678,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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' and 'data' element."); + 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/AtomicRemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs index 73f9a54785..311fc9f248 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs @@ -684,7 +684,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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' and 'data' element."); + 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/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs new file mode 100644 index 0000000000..032fea0ba3 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs @@ -0,0 +1,738 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Relationships +{ + public sealed class AtomicReplaceToManyRelationshipTests + : IClassFixture, OperationsDbContext>> + { + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicReplaceToManyRelationshipTests( + IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + }); + } + + [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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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_missing_type_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + id = 99999999, + relationship = "tracks" + } + } + } + }; + + var route = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 + { + id = existingTrack.StringId, + type = "musicTracks", + relationship = "performers" + }, + data = (object)null + } + } + }; + + var route = "/api/v1/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 + { + id = 99999999, + type = "playlists", + relationship = "tracks" + }, + data = new[] + { + new + { + id = Guid.NewGuid().ToString() + } + } + } + } + }; + + var route = "/api/v1/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 + { + id = Guid.NewGuid().ToString(), + type = "musicTracks", + relationship = "performers" + }, + data = new[] + { + new + { + type = "doesNotExist", + id = 99999999 + } + } + } + } + }; + + var route = "/api/v1/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 + { + id = Guid.NewGuid().ToString(), + type = "musicTracks", + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers" + } + } + } + } + }; + + var route = "/api/v1/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 + { + id = existingCompany.StringId, + type = "recordCompanies", + relationship = "tracks" + }, + data = new[] + { + new + { + type = "musicTracks", + id = trackIds[0].ToString() + }, + new + { + type = "musicTracks", + id = trackIds[1].ToString() + } + } + } + } + }; + + var route = "/api/v1/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_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 + { + id = existingTrack.StringId, + type = "musicTracks", + relationship = "performers" + }, + data = new[] + { + new + { + type = "playlists", + id = 88888888 + } + } + } + } + }; + + var route = "/api/v1/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]"); + } + } +} From 36c4023cd26948fa333d8820d1f760090a218a86 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 17 Dec 2020 18:13:09 +0100 Subject: [PATCH 033/123] added tests for Replace to-one --- .../AtomicOperationsProcessor.cs | 12 +- .../IAtomicOperationsProcessor.cs | 2 +- .../Processors/IAtomicOperationProcessor.cs | 2 +- .../AtomicOperationProcessorResolver.cs | 2 +- .../BaseJsonApiAtomicOperationsController.cs | 5 +- .../Repositories/DbContextExtensions.cs | 64 -- .../EntityFrameworkCoreRepository.cs | 8 + .../Serialization/RequestDeserializer.cs | 35 + .../AtomicAddToToManyRelationshipTests.cs | 12 +- ...AtomicRemoveFromToManyRelationshipTests.cs | 12 +- .../AtomicReplaceToManyRelationshipTests.cs | 12 +- .../AtomicUpdateToOneRelationshipTests.cs | 958 ++++++++++++++++++ 12 files changed, 1034 insertions(+), 90 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs diff --git a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs index 66dc913aba..1880d6b474 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs @@ -88,7 +88,10 @@ public async Task> ProcessAsync(IList> ProcessAsync(IList - /// Processes a request that contains a list of atomic operations. + /// Atomically processes a request that contains a list of operations. /// public interface IAtomicOperationsProcessor { diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IAtomicOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAtomicOperationProcessor.cs index 8bff0e8374..a229475daa 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IAtomicOperationProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAtomicOperationProcessor.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors { /// - /// Processes a single entry in a list of atomic operations. + /// Processes a single entry in a list of operations. /// public interface IAtomicOperationProcessor { diff --git a/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs b/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs index fe732ede46..301674c1aa 100644 --- a/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs @@ -61,7 +61,7 @@ public IAtomicOperationProcessor ResolveProcessor(AtomicOperationObject operatio } } - throw new InvalidOperationException($"Atomic operation code '{operation.Code}' is invalid."); + throw new InvalidOperationException($"Operation code '{operation.Code}' is invalid."); } private IAtomicOperationProcessor Resolve(AtomicOperationObject atomicOperationObject, Type processorInterface) diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs index 8b91399159..c9a818a596 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs @@ -32,8 +32,9 @@ protected BaseJsonApiAtomicOperationsController(IJsonApiOptions options, ILogger } /// - /// Processes a document with atomic operations and returns their results. - /// If none of the operations contain data, HTTP 201 is returned instead 200. + /// Atomically processes a document with operations and returns their results. + /// If processing fails, all changes are reverted. + /// If processing succeeds and none of the operations contain data, HTTP 201 is returned instead 200. /// /// /// The next example creates a new resource. diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index 29f223b6af..83a3f77bf3 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -1,13 +1,9 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Resources; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage; namespace JsonApiDotNetCore.Repositories { @@ -64,64 +60,4 @@ public static void ResetChangeTracker(this DbContext dbContext) } } } - - /// - /// Gets the current transaction or creates a new one. - /// If a transaction already exists, commit, rollback and dispose - /// will not be called. It is assumed the creator of the original - /// transaction should be responsible for disposal. - /// - internal class SafeTransactionProxy : IDbContextTransaction - { - private readonly bool _shouldExecute; - private readonly IDbContextTransaction _transaction; - - private SafeTransactionProxy(IDbContextTransaction transaction, bool shouldExecute) - { - _transaction = transaction; - _shouldExecute = shouldExecute; - } - - public static async Task GetOrCreateAsync(DatabaseFacade databaseFacade) - => (databaseFacade.CurrentTransaction != null) - ? new SafeTransactionProxy(databaseFacade.CurrentTransaction, shouldExecute: false) - : new SafeTransactionProxy(await databaseFacade.BeginTransactionAsync(), shouldExecute: true); - - /// - public Guid TransactionId => _transaction.TransactionId; - - /// - public void Commit() => Proxy(t => t.Commit()); - - /// - public Task CommitAsync(CancellationToken cancellationToken) => Proxy(t => t.CommitAsync(cancellationToken)); - - /// - public void Rollback() => Proxy(t => t.Rollback()); - - /// - public Task RollbackAsync(CancellationToken cancellationToken) => Proxy(t => t.RollbackAsync(cancellationToken)); - - /// - public void Dispose() => Proxy(t => t.Dispose()); - - public ValueTask DisposeAsync() - { - return Proxy(t => t.DisposeAsync()); - } - - private void Proxy(Action func) - { - if(_shouldExecute) - func(_transaction); - } - - private TResult Proxy(Func func) - { - if(_shouldExecute) - return func(_transaction); - - return default; - } - } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 9738f4795b..903e198184 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using System.Transactions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; @@ -399,6 +400,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. + await _dbContext.Database.CurrentTransaction.RollbackAsync(cancellationToken); + } + throw new DataStoreUpdateException(exception); } } diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index 128ea12c8a..efebd73423 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -145,6 +145,14 @@ private void ValidateOperation(AtomicOperationObject operation, int index) atomicOperationIndex: index); } + if (relationship is HasOneAttribute && operation.ManyData != null) + { + throw new JsonApiSerializationException( + "Expected single data element for to-one relationship.", + $"Expected single data element for '{relationship.PublicName}' relationship.", + atomicOperationIndex: index); + } + if (relationship is HasManyAttribute && operation.ManyData == null) { throw new JsonApiSerializationException( @@ -180,6 +188,33 @@ private void ValidateOperation(AtomicOperationObject operation, int index) } } } + + if (operation.SingleData != null) + { + var resourceObject = operation.SingleData; + + if (resourceObject.Type == null) + { + throw new JsonApiSerializationException("The 'data.type' element is required.", null, + atomicOperationIndex: index); + } + + if (resourceObject.Id == null && resourceObject.Lid == null) + { + throw new JsonApiSerializationException("The 'data.id' or 'data.lid' element is required.", null, + atomicOperationIndex: index); + } + + var rightResourceContext = GetExistingResourceContext(resourceObject.Type, index); + if (!rightResourceContext.ResourceType.IsAssignableFrom(relationship.RightType)) + { + var relationshipRightTypeName = ResourceContextProvider.GetResourceContext(relationship.RightType); + + throw new JsonApiSerializationException("Resource type mismatch between 'ref.relationship' and 'data.type' element.", + $@"Expected resource of type '{relationshipRightTypeName}' in 'data.type', instead of '{rightResourceContext.PublicName}'.", + atomicOperationIndex: index); + } + } } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs index 156b3d7d9e..d1e2842817 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs @@ -413,8 +413,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => op = "add", @ref = new { - id = existingTrack.StringId, type = "musicTracks", + id = existingTrack.StringId, relationship = "performers" }, data = (object)null @@ -450,8 +450,8 @@ public async Task Cannot_add_for_missing_type_in_data() op = "add", @ref = new { - id = 99999999, type = "playlists", + id = 99999999, relationship = "tracks" }, data = new[] @@ -493,8 +493,8 @@ public async Task Cannot_add_for_unknown_type_in_data() op = "add", @ref = new { - id = Guid.NewGuid().ToString(), type = "musicTracks", + id = Guid.NewGuid().ToString(), relationship = "performers" }, data = new[] @@ -537,8 +537,8 @@ public async Task Cannot_add_for_missing_ID_in_data() op = "add", @ref = new { - id = Guid.NewGuid().ToString(), type = "musicTracks", + id = Guid.NewGuid().ToString(), relationship = "performers" }, data = new[] @@ -589,8 +589,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => op = "add", @ref = new { - id = existingCompany.StringId, type = "recordCompanies", + id = existingCompany.StringId, relationship = "tracks" }, data = new[] @@ -652,8 +652,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => op = "add", @ref = new { - id = existingTrack.StringId, type = "musicTracks", + id = existingTrack.StringId, relationship = "performers" }, data = new[] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs index 311fc9f248..86075c71b4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs @@ -419,8 +419,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => op = "remove", @ref = new { - id = existingTrack.StringId, type = "musicTracks", + id = existingTrack.StringId, relationship = "performers" }, data = (object)null @@ -456,8 +456,8 @@ public async Task Cannot_remove_for_missing_type_in_data() op = "remove", @ref = new { - id = 99999999, type = "playlists", + id = 99999999, relationship = "tracks" }, data = new[] @@ -499,8 +499,8 @@ public async Task Cannot_remove_for_unknown_type_in_data() op = "remove", @ref = new { - id = Guid.NewGuid().ToString(), type = "musicTracks", + id = Guid.NewGuid().ToString(), relationship = "performers" }, data = new[] @@ -543,8 +543,8 @@ public async Task Cannot_remove_for_missing_ID_in_data() op = "remove", @ref = new { - id = Guid.NewGuid().ToString(), type = "musicTracks", + id = Guid.NewGuid().ToString(), relationship = "performers" }, data = new[] @@ -595,8 +595,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => op = "remove", @ref = new { - id = existingCompany.StringId, type = "recordCompanies", + id = existingCompany.StringId, relationship = "tracks" }, data = new[] @@ -658,8 +658,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => op = "remove", @ref = new { - id = existingTrack.StringId, type = "musicTracks", + id = existingTrack.StringId, relationship = "performers" }, data = new[] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs index 032fea0ba3..13ea6a30dc 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs @@ -465,8 +465,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => op = "update", @ref = new { - id = existingTrack.StringId, type = "musicTracks", + id = existingTrack.StringId, relationship = "performers" }, data = (object)null @@ -502,8 +502,8 @@ public async Task Cannot_replace_for_missing_type_in_data() op = "update", @ref = new { - id = 99999999, type = "playlists", + id = 99999999, relationship = "tracks" }, data = new[] @@ -545,8 +545,8 @@ public async Task Cannot_replace_for_unknown_type_in_data() op = "update", @ref = new { - id = Guid.NewGuid().ToString(), type = "musicTracks", + id = Guid.NewGuid().ToString(), relationship = "performers" }, data = new[] @@ -589,8 +589,8 @@ public async Task Cannot_replace_for_missing_ID_in_data() op = "update", @ref = new { - id = Guid.NewGuid().ToString(), type = "musicTracks", + id = Guid.NewGuid().ToString(), relationship = "performers" }, data = new[] @@ -641,8 +641,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => op = "update", @ref = new { - id = existingCompany.StringId, type = "recordCompanies", + id = existingCompany.StringId, relationship = "tracks" }, data = new[] @@ -704,8 +704,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => op = "update", @ref = new { - id = existingTrack.StringId, type = "musicTracks", + id = existingTrack.StringId, relationship = "performers" }, data = new[] 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..5332dc3665 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs @@ -0,0 +1,958 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Relationships +{ + public sealed class AtomicUpdateToOneRelationshipTests + : IClassFixture, OperationsDbContext>> + { + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicUpdateToOneRelationshipTests( + IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + }); + } + + [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 = "/api/v1/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_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 = "/api/v1/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_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 = "/api/v1/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_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 = "/api/v1/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_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 = "/api/v1/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_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 = "/api/v1/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_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 = "/api/v1/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_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 = "/api/v1/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_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 = "/api/v1/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_missing_type_in_ref() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + id = 99999999, + relationship = "track" + } + } + } + }; + + var route = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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_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 = "/api/v1/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]"); + } + } +} From f72d7b2528a9a2053a6d39b2216ff285318d0d1b Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 17 Dec 2020 18:41:22 +0100 Subject: [PATCH 034/123] reorder tests --- .../Creating/AtomicCreateResourceTests.cs | 102 ++++++++-------- .../Resources/AtomicUpdateResourceTests.cs | 114 +++++++++--------- 2 files changed, 108 insertions(+), 108 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index dcf43431af..ad748baeb1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -83,57 +83,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [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 = "/api/v1/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)); - - 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_resources() { @@ -208,5 +157,56 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }); } + + [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 = "/api/v1/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)); + + 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); + }); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs index 0926792675..4e412fec13 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs @@ -88,40 +88,44 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_update_resource_without_attributes_or_relationships() + public async Task Can_update_resources() { // Arrange - var existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + 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 => { - dbContext.MusicTracks.Add(existingTrack); + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.AddRange(existingTracks); await dbContext.SaveChangesAsync(); }); - var requestBody = new + var operationElements = new List(elementCount); + for (int index = 0; index < elementCount; index++) { - atomic__operations = new[] + operationElements.Add(new { - new + op = "update", + data = new { - op = "update", - data = new + type = "musicTracks", + id = existingTracks[index].StringId, + attributes = new { - type = "musicTracks", - id = existingTrack.StringId, - attributes = new - { - }, - relationships = new - { - } + title = newTrackTitles[index] } } - } - }; + }); + } + var requestBody = new + { + atomic__operations = operationElements + }; + var route = "/api/v1/operations"; // Act @@ -135,56 +139,55 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { var tracksInDatabase = await dbContext.MusicTracks - .Include(musicTrack => musicTrack.OwnedBy) - .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + .ToListAsync(); - tracksInDatabase.Title.Should().Be(existingTrack.Title); - tracksInDatabase.Genre.Should().Be(existingTrack.Genre); + tracksInDatabase.Should().HaveCount(elementCount); - tracksInDatabase.OwnedBy.Should().NotBeNull(); - tracksInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); + 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_resources() + public async Task Can_update_resource_without_attributes_or_relationships() { // Arrange - const int elementCount = 5; - - var existingTracks = _fakers.MusicTrack.Generate(elementCount); - var newTrackTitles = _fakers.MusicTrack.Generate(elementCount).Select(musicTrack => musicTrack.Title).ToArray(); + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.AddRange(existingTracks); + dbContext.MusicTracks.Add(existingTrack); await dbContext.SaveChangesAsync(); }); - var operationElements = new List(elementCount); - for (int index = 0; index < elementCount; index++) + var requestBody = new { - operationElements.Add(new + atomic__operations = new[] { - op = "update", - data = new + new { - type = "musicTracks", - id = existingTracks[index].StringId, - attributes = new + op = "update", + data = new { - title = newTrackTitles[index] + type = "musicTracks", + id = existingTrack.StringId, + attributes = new + { + }, + relationships = new + { + } } } - }); - } - - var requestBody = new - { - atomic__operations = operationElements + } }; - + var route = "/api/v1/operations"; // Act @@ -198,17 +201,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { var tracksInDatabase = await dbContext.MusicTracks - .ToListAsync(); - - tracksInDatabase.Should().HaveCount(elementCount); + .Include(musicTrack => musicTrack.OwnedBy) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); - for (int index = 0; index < elementCount; index++) - { - var trackInDatabase = tracksInDatabase.Single(musicTrack => musicTrack.Id == existingTracks[index].Id); + tracksInDatabase.Title.Should().Be(existingTrack.Title); + tracksInDatabase.Genre.Should().Be(existingTrack.Genre); - trackInDatabase.Title.Should().Be(newTrackTitles[index]); - trackInDatabase.Genre.Should().Be(existingTracks[index].Genre); - } + tracksInDatabase.OwnedBy.Should().NotBeNull(); + tracksInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); }); } } From 4be8af657891c31f806faa71f3e214409fe9a4df Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 17 Dec 2020 18:50:57 +0100 Subject: [PATCH 035/123] fail on href usage --- .../Creating/AtomicCreateResourceTests.cs | 31 ++++++++++++++++++ .../AtomicAddToToManyRelationshipTests.cs | 31 ++++++++++++++++++ ...AtomicRemoveFromToManyRelationshipTests.cs | 31 ++++++++++++++++++ .../AtomicReplaceToManyRelationshipTests.cs | 31 ++++++++++++++++++ .../AtomicUpdateToOneRelationshipTests.cs | 31 ++++++++++++++++++ .../Resources/AtomicUpdateResourceTests.cs | 32 +++++++++++++++++++ 6 files changed, 187 insertions(+) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index ad748baeb1..d9004958ea 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -208,5 +208,36 @@ await _testContext.RunOnDatabaseAsync(async dbContext => performerInDatabase.BornAt.Should().Be(default); }); } + + [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 = "/api/v1/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]"); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs index d1e2842817..753ab533ad 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs @@ -250,6 +250,37 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [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 = "/api/v1/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() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs index 86075c71b4..0d34844054 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs @@ -256,6 +256,37 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [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 = "/api/v1/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() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs index 13ea6a30dc..894fa22121 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs @@ -302,6 +302,37 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [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 = "/api/v1/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() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs index 5332dc3665..00ada0b9c0 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs @@ -542,6 +542,37 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [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 = "/api/v1/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() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs index 4e412fec13..617a8da9a7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs @@ -3,6 +3,7 @@ using System.Net; using System.Threading.Tasks; using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.EntityFrameworkCore; @@ -211,5 +212,36 @@ await _testContext.RunOnDatabaseAsync(async dbContext => tracksInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); }); } + + [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 = "/api/v1/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]"); + } } } From 87d2bda202a5f3307c978aaa9fcce40bbcb9c996 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 17 Dec 2020 19:15:19 +0100 Subject: [PATCH 036/123] Fail when using both 'id' and 'lid' --- .../Serialization/RequestDeserializer.cs | 6 +- .../Deleting/AtomicDeleteResourceTests.cs | 40 ++++++++- .../AtomicAddToToManyRelationshipTests.cs | 82 +++++++++++++++++++ ...AtomicRemoveFromToManyRelationshipTests.cs | 82 +++++++++++++++++++ .../AtomicReplaceToManyRelationshipTests.cs | 82 +++++++++++++++++++ .../AtomicUpdateToOneRelationshipTests.cs | 79 ++++++++++++++++++ 6 files changed, 366 insertions(+), 5 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index efebd73423..9a28bc085f 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -108,7 +108,7 @@ private void ValidateOperation(AtomicOperationObject operation, int index) var resourceContext = GetExistingResourceContext(operation.Ref.Type, index); - if (operation.Ref.Id == null && operation.Ref.Lid == null) + if ((operation.Ref.Id == null && operation.Ref.Lid == null) || (operation.Ref.Id != null && operation.Ref.Lid != null)) { throw new JsonApiSerializationException("The 'ref.id' or 'ref.lid' element is required.", null, atomicOperationIndex: index); @@ -171,7 +171,7 @@ private void ValidateOperation(AtomicOperationObject operation, int index) atomicOperationIndex: index); } - if (resourceObject.Id == null && resourceObject.Lid == null) + if ((resourceObject.Id == null && resourceObject.Lid == null) || (resourceObject.Id != null && resourceObject.Lid != null)) { throw new JsonApiSerializationException("The 'data[].id' or 'data[].lid' element is required.", null, atomicOperationIndex: index); @@ -199,7 +199,7 @@ private void ValidateOperation(AtomicOperationObject operation, int index) atomicOperationIndex: index); } - if (resourceObject.Id == null && resourceObject.Lid == null) + if ((resourceObject.Id == null && resourceObject.Lid == null) || (resourceObject.Id != null && resourceObject.Lid != null)) { throw new JsonApiSerializationException("The 'data.id' or 'data.lid' element is required.", null, atomicOperationIndex: index); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs index 5096d22469..591ac11cf9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs @@ -508,7 +508,43 @@ public async Task Cannot_delete_resource_for_missing_ID() responseDocument.Errors[0].Detail.Should().BeNull(); 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 = "/api/v1/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() { @@ -543,7 +579,7 @@ public async Task Cannot_delete_resource_for_unknown_ID() 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() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs index 753ab533ad..7a653272ac 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs @@ -387,6 +387,43 @@ public async Task Cannot_add_for_missing_ID_in_ref() 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 = "/api/v1/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_relationship_in_ref() { @@ -598,6 +635,51 @@ public async Task Cannot_add_for_missing_ID_in_data() 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 = "/api/v1/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() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs index 0d34844054..713155f30e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs @@ -393,6 +393,43 @@ public async Task Cannot_remove_for_missing_ID_in_ref() 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 = "/api/v1/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() { @@ -604,6 +641,51 @@ public async Task Cannot_remove_for_missing_ID_in_data() 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 = "/api/v1/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() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs index 894fa22121..2b51a27736 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs @@ -439,6 +439,43 @@ public async Task Cannot_replace_for_missing_ID_in_ref() 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 = "/api/v1/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() { @@ -650,6 +687,51 @@ public async Task Cannot_replace_for_missing_ID_in_data() 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 = "/api/v1/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() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs index 00ada0b9c0..a5cc78d633 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs @@ -679,6 +679,43 @@ public async Task Cannot_create_for_missing_ID_in_ref() 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 = "/api/v1/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() { @@ -888,6 +925,48 @@ public async Task Cannot_create_for_missing_ID_in_data() 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 = "/api/v1/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() { From fca7192a5acf16cc6d4eaaddb656630f51d6de56 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 18 Dec 2020 11:25:02 +0100 Subject: [PATCH 037/123] Tests for create resource --- .../Serialization/RequestDeserializer.cs | 6 + .../Creating/AtomicCreateResourceTests.cs | 433 ++++++++++++++++++ .../AtomicOperations/Lyric.cs | 4 + .../AtomicAddToToManyRelationshipTests.cs | 35 ++ 4 files changed, 478 insertions(+) diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index 9a28bc085f..d21c18d1ad 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -100,6 +100,12 @@ private void ValidateOperation(AtomicOperationObject operation, int index) if (operation.Ref != null) { + if (operation.Code == AtomicOperationCode.Add && operation.Ref.Relationship == null) + { + throw new JsonApiSerializationException("The 'ref.relationship' element is required.", null, + atomicOperationIndex: index); + } + if (operation.Ref.Type == null) { throw new JsonApiSerializationException("The 'ref.type' element is required.", null, diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index d9004958ea..5364803fcf 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -4,6 +4,7 @@ using System.Net; using System.Threading.Tasks; using FluentAssertions; +using FluentAssertions.Extensions; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; using Microsoft.AspNetCore.Mvc.ApplicationParts; @@ -70,6 +71,7 @@ public async Task Can_create_resource() 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); @@ -134,6 +136,7 @@ public async Task Can_create_resources() responseDocument.Results[index].SingleData.Attributes["lengthInSeconds"].Should().BeApproximately(newTracks[index].LengthInSeconds); 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)); @@ -196,6 +199,7 @@ public async Task Can_create_resource_without_attributes_or_relationships() 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); @@ -209,6 +213,153 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } + [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 = "/api/v1/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 = "/api/v1/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(Skip = "TODO: Make this test work")] + 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 = "/api/v1/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("TODO: Specifying the resource ID in POST requests 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() { @@ -239,5 +390,287 @@ public async Task Cannot_create_resource_for_href_element() 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 = "/api/v1/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(Skip = "TODO: Make this test work")] + 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 = "/api/v1/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(Skip = "TODO: Make this test work")] + 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 = "/api/v1/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(Skip = "TODO: Make this test work")] + 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 = "/api/v1/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(Skip = "TODO: Make this test work")] + 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 = "/api/v1/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 'not-a-valid-time' of type 'String' to type 'DateTimeOffset'."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [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 = "/api/v1/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/Lyric.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Lyric.cs index f60193662c..29e0afe04b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Lyric.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Lyric.cs @@ -1,3 +1,4 @@ +using System; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -11,6 +12,9 @@ public sealed class Lyric : Identifiable [Attr] public string Text { get; set; } + [Attr(Capabilities = AttrCapabilities.None)] + public DateTimeOffset CreatedAt { get; set; } + [HasOne] public MusicTrack Track { get; set; } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs index 7a653272ac..b63a904195 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs @@ -424,6 +424,41 @@ public async Task Cannot_add_for_ID_and_local_ID_in_ref() 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 = "/api/v1/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() { From 0dfb3233285572566dd14f4c58c8174715440e9b Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 5 Jan 2021 18:08:13 +0100 Subject: [PATCH 038/123] Pushed down all operation serialization/deserialization into the Serialization layer. This relieves controllers and processors from dealing with json:api objects directly. --- .../Controllers/AtomicOperationsController.cs | 6 +- .../AtomicOperationsProcessor.cs | 231 +++--------------- .../IAtomicOperationsProcessor.cs | 4 +- .../AtomicOperations/ILocalIdTracker.cs | 6 +- .../AtomicOperations/LocalIdTracker.cs | 72 +++--- .../Processors/AddToRelationshipProcessor.cs | 24 +- .../Processors/BaseRelationshipProcessor.cs | 42 ++-- .../Processors/CreateProcessor.cs | 37 +-- .../Processors/DeleteProcessor.cs | 9 +- .../Processors/IAtomicOperationProcessor.cs | 4 +- .../RemoveFromRelationshipProcessor.cs | 24 +- .../Processors/SetRelationshipProcessor.cs | 36 +-- .../Processors/UpdateProcessor.cs | 53 +--- .../AtomicOperationProcessorResolver.cs | 72 ++---- .../IAtomicOperationProcessorResolver.cs | 6 +- .../Configuration/JsonApiValidationFilter.cs | 3 +- .../BaseJsonApiAtomicOperationsController.cs | 73 ++++-- .../JsonApiAtomicOperationsController.cs | 12 +- .../Middleware/IJsonApiRequest.cs | 8 +- .../Middleware/JsonApiRequest.cs | 20 +- .../Middleware/OperationKind.cs | 15 ++ .../EntityFrameworkCoreRepository.cs | 1 - .../Resources/IIdentifiable.cs | 5 + .../Resources/Identifiable.cs | 4 + .../Resources/IdentifiableComparer.cs | 11 +- .../Resources/OperationContainer.cs | 25 ++ .../AtomicOperationsResponseSerializer.cs | 36 ++- .../Serialization/BaseDeserializer.cs | 49 +++- .../ResourceIdentifierObjectComparer.cs | 5 +- .../Client/Internal/ResponseDeserializer.cs | 2 + .../Serialization/IJsonApiDeserializer.cs | 7 - .../Serialization/RequestDeserializer.cs | 130 +++++++++- .../Creating/AtomicCreateResourceTests.cs | 14 +- .../Mixed/AtomicLocalIdTests.cs | 54 +++- .../Mixed/MixedOperationsTests.cs | 8 +- test/UnitTests/Internal/TypeHelper_Tests.cs | 1 + .../Serialization/DeserializerTestsSetup.cs | 2 + 37 files changed, 555 insertions(+), 556 deletions(-) create mode 100644 src/JsonApiDotNetCore/Middleware/OperationKind.cs create mode 100644 src/JsonApiDotNetCore/Resources/OperationContainer.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/AtomicOperationsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/AtomicOperationsController.cs index 4b8958dd01..43d8aaff62 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/AtomicOperationsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/AtomicOperationsController.cs @@ -2,6 +2,8 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -11,8 +13,8 @@ namespace JsonApiDotNetCoreExample.Controllers public class AtomicOperationsController : JsonApiAtomicOperationsController { public AtomicOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IAtomicOperationsProcessor processor) - : base(options, loggerFactory, processor) + IAtomicOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, loggerFactory, processor, request, targetedFields) { } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs index 1880d6b474..63fbee5036 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs @@ -5,15 +5,11 @@ using System.Threading; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Microsoft.EntityFrameworkCore; namespace JsonApiDotNetCore.AtomicOperations @@ -22,51 +18,41 @@ namespace JsonApiDotNetCore.AtomicOperations public class AtomicOperationsProcessor : IAtomicOperationsProcessor { private readonly IAtomicOperationProcessorResolver _resolver; - private readonly IJsonApiOptions _options; private readonly ILocalIdTracker _localIdTracker; - private readonly DbContext _dbContext; private readonly IJsonApiRequest _request; private readonly ITargetedFields _targetedFields; private readonly IResourceContextProvider _resourceContextProvider; - private readonly IObjectModelValidator _validator; - private readonly IJsonApiDeserializer _deserializer; + private readonly DbContext _dbContext; - public AtomicOperationsProcessor(IAtomicOperationProcessorResolver resolver, IJsonApiOptions options, + public AtomicOperationsProcessor(IAtomicOperationProcessorResolver resolver, ILocalIdTracker localIdTracker, IJsonApiRequest request, ITargetedFields targetedFields, - IResourceContextProvider resourceContextProvider, IEnumerable dbContextResolvers, - IObjectModelValidator validator, IJsonApiDeserializer deserializer) + IResourceContextProvider resourceContextProvider, IEnumerable dbContextResolvers) { if (dbContextResolvers == null) throw new ArgumentNullException(nameof(dbContextResolvers)); _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); - _options = options ?? throw new ArgumentNullException(nameof(options)); _localIdTracker = localIdTracker ?? throw new ArgumentNullException(nameof(localIdTracker)); _request = request ?? throw new ArgumentNullException(nameof(request)); _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); - _validator = validator ?? throw new ArgumentNullException(nameof(validator)); - _deserializer = deserializer ?? throw new ArgumentNullException(nameof(deserializer)); var resolvers = dbContextResolvers.ToArray(); if (resolvers.Length != 1) { throw new InvalidOperationException( - "TODO: At least one DbContext is required for atomic operations. Multiple DbContexts are currently not supported."); + "TODO: @OPS: At least one DbContext is required for atomic operations. Multiple DbContexts are currently not supported."); } _dbContext = resolvers[0].GetContext(); } - public async Task> ProcessAsync(IList operations, CancellationToken cancellationToken) + public async Task> ProcessAsync(IList operations, CancellationToken cancellationToken) { if (operations == null) throw new ArgumentNullException(nameof(operations)); - if (_options.ValidateModelState) - { - ValidateModelState(operations); - } + // TODO: @OPS: Consider to validate local:id usage upfront. - var results = new List(); + var results = new List(); await using var transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken); try @@ -120,217 +106,58 @@ public async Task> ProcessAsync(IList operations) - { - var violations = new List(); - - int index = 0; - foreach (var operation in operations) - { - if (operation.Ref?.Relationship == null && operation.SingleData != null) - { - PrepareForOperation(operation); - - var validationContext = new ActionContext(); - - var model = _deserializer.CreateResourceFromObject(operation.SingleData); - _validator.Validate(validationContext, null, string.Empty, model); - - 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, model.GetType(), error); - violations.Add(violation); - } - } - } - } - - index++; - } - - if (violations.Any()) - { - var namingStrategy = _options.SerializerContractResolver.NamingStrategy; - throw new InvalidModelStateException(violations, _options.IncludeExceptionStackTraceInErrors, namingStrategy); - } - } - - private async Task ProcessOperation(AtomicOperationObject operation, CancellationToken cancellationToken) + private async Task ProcessOperation(OperationContainer operation, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - string resourceName = null; + TrackLocalIds(operation); - if (operation.Ref != null) - { - resourceName = operation.Ref.Type; - } - else - { - if (operation.SingleData != null) - { - resourceName = operation.SingleData.Type; - if (resourceName == null) - { - throw new JsonApiException(new Error(HttpStatusCode.BadRequest) - { - Title = "The data.type element is required." - }); - } - } - else if (operation.ManyData != null && operation.ManyData.Any()) - { - foreach (var resourceObject in operation.ManyData) - { - resourceName = resourceObject.Type; - if (resourceName == null) - { - throw new JsonApiException(new Error(HttpStatusCode.BadRequest) - { - Title = "The data[].type element is required." - }); - } - - // TODO: Verify all are of the same (or compatible) type. - } - } - } - - if (resourceName == null) - { - throw new InvalidOperationException("TODO: Failed to determine targeted resource."); - } + _targetedFields.Attributes = operation.TargetedFields.Attributes; + _targetedFields.Relationships = operation.TargetedFields.Relationships; + + _request.CopyFrom(operation.Request); - bool isResourceAdd = operation.Code == AtomicOperationCode.Add && operation.Ref == null; - - if (isResourceAdd && operation.SingleData?.Lid != null) - { - _localIdTracker.Declare(operation.SingleData.Lid, operation.SingleData.Type); - } - - ReplaceLocalIdsInOperationObject(operation, isResourceAdd); - - var resourceContext = _resourceContextProvider.GetResourceContext(resourceName); - if (resourceContext == null) - { - throw new JsonApiException(new Error(HttpStatusCode.BadRequest) - { - Title = "Request body includes unknown resource type.", - Detail = $"Resource type '{resourceName}' does not exist." - }); - } - - PrepareForOperation(operation); - var processor = _resolver.ResolveProcessor(operation); return await processor.ProcessAsync(operation, cancellationToken); } - private void PrepareForOperation(AtomicOperationObject operation) + private void TrackLocalIds(OperationContainer operation) { - _targetedFields.Attributes.Clear(); - _targetedFields.Relationships.Clear(); - - var resourceName = operation.GetResourceTypeName(); - var primaryResourceContext = _resourceContextProvider.GetResourceContext(resourceName); - - ((JsonApiRequest) _request).OperationCode = operation.Code; - ((JsonApiRequest)_request).PrimaryResource = primaryResourceContext; - - if (operation.Ref != null) + if (operation.Kind == OperationKind.CreateResource) { - ((JsonApiRequest)_request).PrimaryId = operation.Ref.Id; - - if (operation.Ref?.Relationship != null) - { - var relationship = primaryResourceContext.Relationships.SingleOrDefault(relationship => relationship.PublicName == operation.Ref.Relationship); - if (relationship == null) - { - throw new InvalidOperationException("TODO: Relationship does not exist."); - } - - var secondaryResource = _resourceContextProvider.GetResourceContext(relationship.RightType); - if (secondaryResource == null) - { - throw new InvalidOperationException("TODO: Secondary resource does not exist."); - } - - ((JsonApiRequest)_request).Relationship = relationship; - ((JsonApiRequest)_request).SecondaryResource = secondaryResource; - - _targetedFields.Relationships.Add(relationship); - } + DeclareLocalId(operation.Resource); } else { - ((JsonApiRequest)_request).PrimaryId = null; - ((JsonApiRequest)_request).Relationship = null; - ((JsonApiRequest)_request).SecondaryResource = null; - } - } - - private void ReplaceLocalIdsInOperationObject(AtomicOperationObject operation, bool isResourceAdd) - { - if (operation.Ref != null) - { - ReplaceLocalIdInResourceIdentifierObject(operation.Ref); + AssignStringId(operation.Resource); } - if (operation.SingleData != null) + foreach (var relationship in operation.TargetedFields.Relationships) { - ReplaceLocalIdsInResourceObject(operation.SingleData, isResourceAdd); - } + var rightValue = relationship.GetValue(operation.Resource); - if (operation.ManyData != null) - { - foreach (var resourceObject in operation.ManyData) + foreach (var rightResource in TypeHelper.ExtractResources(rightValue)) { - ReplaceLocalIdsInResourceObject(resourceObject, isResourceAdd); + AssignStringId(rightResource); } } } - private void ReplaceLocalIdsInResourceObject(ResourceObject resourceObject, bool isResourceAdd) + private void DeclareLocalId(IIdentifiable resource) { - if (!isResourceAdd) + if (resource.LocalId != null) { - ReplaceLocalIdInResourceIdentifierObject(resourceObject); - } - - if (resourceObject.Relationships != null) - { - foreach (var relationshipEntry in resourceObject.Relationships.Values) - { - if (relationshipEntry.IsManyData) - { - foreach (var relationship in relationshipEntry.ManyData) - { - ReplaceLocalIdInResourceIdentifierObject(relationship); - } - } - else - { - var relationship = relationshipEntry.SingleData; - - if (relationship != null) - { - ReplaceLocalIdInResourceIdentifierObject(relationship); - } - } - } + var resourceContext = _resourceContextProvider.GetResourceContext(resource.GetType()); + _localIdTracker.Declare(resource.LocalId, resourceContext.PublicName); } } - private void ReplaceLocalIdInResourceIdentifierObject(ResourceIdentifierObject resourceIdentifierObject) + private void AssignStringId(IIdentifiable resource) { - if (resourceIdentifierObject.Lid != null) + if (resource.LocalId != null) { - resourceIdentifierObject.Id = - _localIdTracker.GetValue(resourceIdentifierObject.Lid, resourceIdentifierObject.Type); + var resourceContext = _resourceContextProvider.GetResourceContext(resource.GetType()); + resource.StringId = _localIdTracker.GetValue(resource.LocalId, resourceContext.PublicName); } } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsProcessor.cs index 2119af38e2..1662982749 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsProcessor.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; -using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Resources; namespace JsonApiDotNetCore.AtomicOperations { @@ -10,6 +10,6 @@ namespace JsonApiDotNetCore.AtomicOperations /// public interface IAtomicOperationsProcessor { - Task> ProcessAsync(IList operations, CancellationToken cancellationToken); + Task> ProcessAsync(IList operations, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs index dc85d9a0fb..97c661bf2a 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs @@ -8,16 +8,16 @@ public interface ILocalIdTracker /// /// Declares a local ID without assigning a server-generated value. /// - void Declare(string lid, string type); + void Declare(string localId, string resourceType); /// /// Assigns a server-generated ID value to a previously declared local ID. /// - void Assign(string lid, string type, string id); + void Assign(string localId, string resourceType, string stringId); /// /// Gets the server-assigned ID for the specified local ID. /// - string GetValue(string lid, string type); + string GetValue(string localId, string resourceType); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs index 8c011fdf28..d4737780e5 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs @@ -9,98 +9,108 @@ namespace JsonApiDotNetCore.AtomicOperations /// public sealed class LocalIdTracker : ILocalIdTracker { - private readonly IDictionary _idsTracked = new Dictionary(); + private readonly IDictionary _idsTracked = new Dictionary(); /// - public void Declare(string lid, string type) + public void Declare(string localId, string resourceType) { - AssertIsNotDeclared(lid); + if (localId == null) throw new ArgumentNullException(nameof(localId)); + if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); - _idsTracked[lid] = new LocalItem(type); + AssertIsNotDeclared(localId); + + _idsTracked[localId] = new LocalIdState(resourceType); } - private void AssertIsNotDeclared(string lid) + private void AssertIsNotDeclared(string localId) { - if (_idsTracked.ContainsKey(lid)) + 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 '{lid}' is already defined at this point." + Detail = $"Another local ID with name '{localId}' is already defined at this point." }); } } /// - public void Assign(string lid, string type, string id) + public void Assign(string localId, string resourceType, string stringId) { - AssertIsDeclared(lid); + 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[lid]; + var item = _idsTracked[localId]; - AssertSameResourceType(type, item.Type, lid); + AssertSameResourceType(resourceType, item.ResourceType, localId); - if (item.IdValue != null) + if (item.ServerId != null) { - throw new InvalidOperationException($"Cannot reassign to existing local ID '{lid}'."); + throw new InvalidOperationException($"Cannot reassign to existing local ID '{localId}'."); } - item.IdValue = id; + item.ServerId = stringId; } /// - public string GetValue(string lid, string type) + public string GetValue(string localId, string resourceType) { - AssertIsDeclared(lid); + if (localId == null) throw new ArgumentNullException(nameof(localId)); + if (resourceType == null) throw new ArgumentNullException(nameof(resourceType)); + + AssertIsDeclared(localId); - var item = _idsTracked[lid]; + var item = _idsTracked[localId]; - AssertSameResourceType(type, item.Type, lid); + AssertSameResourceType(resourceType, item.ResourceType, localId); - if (item.IdValue == null) + 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 '{lid}' 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.IdValue; + return item.ServerId; } - private void AssertIsDeclared(string lid) + private void AssertIsDeclared(string localId) { - if (!_idsTracked.ContainsKey(lid)) + 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 '{lid}' 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 lid) + 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 '{lid}' belongs to resource type '{declaredType}' instead of '{currentType}'." + Detail = $"Local ID '{localId}' belongs to resource type '{declaredType}' instead of '{currentType}'." }); } } - private sealed class LocalItem + private sealed class LocalIdState { - public string Type { get; } - public string IdValue { get; set; } + public string ResourceType { get; } + public string ServerId { get; set; } - public LocalItem(string type) + public LocalIdState(string resourceType) { - Type = type; + ResourceType = resourceType; } } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs index 3227d80365..31f810298f 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs @@ -1,10 +1,7 @@ using System; using System.Threading; using System.Threading.Tasks; -using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCore.Services; namespace JsonApiDotNetCore.AtomicOperations.Processors @@ -15,31 +12,27 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors /// The resource type. /// The resource identifier type. public class AddToRelationshipProcessor - : BaseRelationshipProcessor, IAddToRelationshipProcessor + : BaseRelationshipProcessor, IAddToRelationshipProcessor where TResource : class, IIdentifiable { private readonly IAddToRelationshipService _service; - private readonly IJsonApiRequest _request; - public AddToRelationshipProcessor(IAddToRelationshipService service, - IResourceFactory resourceFactory, IJsonApiRequest request, IJsonApiDeserializer deserializer) - : base(resourceFactory, deserializer, request) + public AddToRelationshipProcessor(IAddToRelationshipService service) { _service = service ?? throw new ArgumentNullException(nameof(service)); - _request = request ?? throw new ArgumentNullException(nameof(request)); } /// - public async Task ProcessAsync(AtomicOperationObject operation, CancellationToken cancellationToken) + public async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { if (operation == null) throw new ArgumentNullException(nameof(operation)); - var primaryId = GetPrimaryId(operation.Ref.Id); + var primaryId = (TId) operation.Resource.GetTypedId(); var secondaryResourceIds = GetSecondaryResourceIds(operation); - await _service.AddToToManyRelationshipAsync(primaryId, _request.Relationship.PublicName, secondaryResourceIds, cancellationToken); + await _service.AddToToManyRelationshipAsync(primaryId, operation.Request.Relationship.PublicName, secondaryResourceIds, cancellationToken); - return new AtomicResultObject(); + return null; } } @@ -51,9 +44,8 @@ public class AddToRelationshipProcessor : AddToRelationshipProcessor, IAddToRelationshipProcessor where TResource : class, IIdentifiable { - public AddToRelationshipProcessor(IAddToRelationshipService service, - IResourceFactory resourceFactory, IJsonApiRequest request, IJsonApiDeserializer deserializer) - : base(service, resourceFactory, request, deserializer) + public AddToRelationshipProcessor(IAddToRelationshipService service) + : base(service) { } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/BaseRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/BaseRelationshipProcessor.cs index 84708d3f16..685c17d558 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/BaseRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/BaseRelationshipProcessor.cs @@ -1,45 +1,33 @@ -using System; using System.Collections.Generic; -using JsonApiDotNetCore.Middleware; +using System.Linq; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.AtomicOperations.Processors { - public abstract class BaseRelationshipProcessor + public abstract class BaseRelationshipProcessor { - private readonly IResourceFactory _resourceFactory; - protected readonly IJsonApiDeserializer _deserializer; - private readonly IJsonApiRequest _request; - - protected BaseRelationshipProcessor(IResourceFactory resourceFactory, IJsonApiDeserializer deserializer, - IJsonApiRequest request) - { - _resourceFactory = resourceFactory ?? throw new ArgumentNullException(nameof(resourceFactory)); - _deserializer = deserializer ?? throw new ArgumentNullException(nameof(deserializer)); - _request = request ?? throw new ArgumentNullException(nameof(request)); - } - - protected TId GetPrimaryId(string stringId) + protected ISet GetSecondaryResourceIds(OperationContainer operation) { - IIdentifiable primaryResource = _resourceFactory.CreateInstance(_request.PrimaryResource.ResourceType); - primaryResource.StringId = stringId; + var relationship = operation.Request.Relationship; + var rightValue = relationship.GetValue(operation.Resource); - return (TId) primaryResource.GetTypedId(); + var rightResources = TypeHelper.ExtractResources(rightValue); + return rightResources.ToHashSet(IdentifiableComparer.Instance); } - protected HashSet GetSecondaryResourceIds(AtomicOperationObject operation) + protected object GetSecondaryResourceIdOrIds(OperationContainer operation) { - var secondaryResourceIds = new HashSet(IdentifiableComparer.Instance); + var relationship = operation.Request.Relationship; + var rightValue = relationship.GetValue(operation.Resource); - foreach (var resourceObject in operation.ManyData) + if (relationship is HasManyAttribute) { - IIdentifiable rightResource = _deserializer.CreateResourceFromObject(resourceObject); - secondaryResourceIds.Add(rightResource); + var rightResources = TypeHelper.ExtractResources(rightValue); + return rightResources.ToHashSet(IdentifiableComparer.Instance); } - return secondaryResourceIds; + return rightValue; } } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs index d60d94efe8..6d1843804e 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs @@ -3,9 +3,6 @@ using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Building; -using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCore.Services; namespace JsonApiDotNetCore.AtomicOperations.Processors @@ -20,50 +17,33 @@ public class CreateProcessor : ICreateProcessor { private readonly ICreateService _service; private readonly ILocalIdTracker _localIdTracker; - private readonly IJsonApiDeserializer _deserializer; - private readonly IResourceObjectBuilder _resourceObjectBuilder; private readonly IResourceContextProvider _resourceContextProvider; public CreateProcessor(ICreateService service, ILocalIdTracker localIdTracker, - IJsonApiDeserializer deserializer, IResourceObjectBuilder resourceObjectBuilder, IResourceContextProvider resourceContextProvider) { _service = service ?? throw new ArgumentNullException(nameof(service)); _localIdTracker = localIdTracker ?? throw new ArgumentNullException(nameof(localIdTracker)); - _deserializer = deserializer ?? throw new ArgumentNullException(nameof(deserializer)); - _resourceObjectBuilder = resourceObjectBuilder ?? throw new ArgumentNullException(nameof(resourceObjectBuilder)); _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); } /// - public async Task ProcessAsync(AtomicOperationObject operation, + public async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { if (operation == null) throw new ArgumentNullException(nameof(operation)); - var model = (TResource) _deserializer.CreateResourceFromObject(operation.SingleData); + var newResource = await _service.CreateAsync((TResource) operation.Resource, cancellationToken); - var newResource = await _service.CreateAsync(model, cancellationToken); - - if (operation.SingleData.Lid != null) - { - var serverId = newResource == null ? operation.SingleData.Id : newResource.StringId; - _localIdTracker.Assign(operation.SingleData.Lid, operation.SingleData.Type, serverId); - } - - if (newResource != null) + if (operation.Resource.LocalId != null) { - ResourceContext resourceContext = - _resourceContextProvider.GetResourceContext(operation.SingleData.Type); + var serverId = newResource != null ? newResource.StringId : operation.Resource.StringId; + var resourceContext = _resourceContextProvider.GetResourceContext(); - return new AtomicResultObject - { - Data = _resourceObjectBuilder.Build(newResource, resourceContext.Attributes, - resourceContext.Relationships) - }; + _localIdTracker.Assign(operation.Resource.LocalId, resourceContext.PublicName, serverId); } - return new AtomicResultObject(); + return newResource; } } @@ -76,9 +56,8 @@ public class CreateProcessor where TResource : class, IIdentifiable { public CreateProcessor(ICreateService service, ILocalIdTracker localIdTracker, - IJsonApiDeserializer deserializer, IResourceObjectBuilder resourceObjectBuilder, IResourceContextProvider resourceContextProvider) - : base(service, localIdTracker, deserializer, resourceObjectBuilder, resourceContextProvider) + : base(service, localIdTracker, resourceContextProvider) { } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs index 085873377b..ff3288ffb0 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs @@ -1,10 +1,7 @@ using System; -using System.Net; using System.Threading; using System.Threading.Tasks; -using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCore.Services; namespace JsonApiDotNetCore.AtomicOperations.Processors @@ -21,14 +18,14 @@ public DeleteProcessor(IDeleteService service) } /// - public async Task ProcessAsync(AtomicOperationObject operation, CancellationToken cancellationToken) + public async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { if (operation == null) throw new ArgumentNullException(nameof(operation)); - var id = (TId) TypeHelper.ConvertType(operation.Ref.Id, typeof(TId)); + var id = (TId) operation.Resource.GetTypedId(); await _service.DeleteAsync(id, cancellationToken); - return new AtomicResultObject(); + return null; } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IAtomicOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAtomicOperationProcessor.cs index a229475daa..a838254c78 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IAtomicOperationProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAtomicOperationProcessor.cs @@ -1,6 +1,6 @@ using System.Threading; using System.Threading.Tasks; -using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Resources; namespace JsonApiDotNetCore.AtomicOperations.Processors { @@ -9,6 +9,6 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors /// public interface IAtomicOperationProcessor { - Task ProcessAsync(AtomicOperationObject operation, CancellationToken cancellationToken); + Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs index ce2471ea89..d0dcc1d110 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs @@ -1,10 +1,7 @@ using System; using System.Threading; using System.Threading.Tasks; -using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCore.Services; namespace JsonApiDotNetCore.AtomicOperations.Processors @@ -15,31 +12,27 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors /// /// public class RemoveFromRelationshipProcessor - : BaseRelationshipProcessor, IRemoveFromRelationshipProcessor + : BaseRelationshipProcessor, IRemoveFromRelationshipProcessor where TResource : class, IIdentifiable { private readonly IRemoveFromRelationshipService _service; - private readonly IJsonApiRequest _request; - public RemoveFromRelationshipProcessor(IRemoveFromRelationshipService service, - IResourceFactory resourceFactory, IJsonApiRequest request, IJsonApiDeserializer deserializer) - : base(resourceFactory, deserializer, request) + public RemoveFromRelationshipProcessor(IRemoveFromRelationshipService service) { _service = service ?? throw new ArgumentNullException(nameof(service)); - _request = request ?? throw new ArgumentNullException(nameof(request)); } /// - public async Task ProcessAsync(AtomicOperationObject operation, CancellationToken cancellationToken) + public async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { if (operation == null) throw new ArgumentNullException(nameof(operation)); - var primaryId = GetPrimaryId(operation.Ref.Id); + var primaryId = (TId) operation.Resource.GetTypedId(); var secondaryResourceIds = GetSecondaryResourceIds(operation); - await _service.RemoveFromToManyRelationshipAsync(primaryId, _request.Relationship.PublicName, secondaryResourceIds, cancellationToken); + await _service.RemoveFromToManyRelationshipAsync(primaryId, operation.Request.Relationship.PublicName, secondaryResourceIds, cancellationToken); - return new AtomicResultObject(); + return null; } } @@ -51,9 +44,8 @@ public class RemoveFromRelationshipProcessor : RemoveFromRelationshipProcessor, IAddToRelationshipProcessor where TResource : class, IIdentifiable { - public RemoveFromRelationshipProcessor(IRemoveFromRelationshipService service, - IResourceFactory resourceFactory, IJsonApiRequest request, IJsonApiDeserializer deserializer) - : base(service, resourceFactory, request, deserializer) + public RemoveFromRelationshipProcessor(IRemoveFromRelationshipService service) + : base(service) { } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs index 27ed541ff8..d08c310690 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs @@ -1,10 +1,7 @@ using System; using System.Threading; using System.Threading.Tasks; -using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCore.Services; namespace JsonApiDotNetCore.AtomicOperations.Processors @@ -15,41 +12,27 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors /// The resource type. /// The resource identifier type. public class SetRelationshipProcessor - : BaseRelationshipProcessor, ISetRelationshipProcessor + : BaseRelationshipProcessor, ISetRelationshipProcessor where TResource : class, IIdentifiable { private readonly ISetRelationshipService _service; - private readonly IJsonApiRequest _request; - public SetRelationshipProcessor(ISetRelationshipService service, - IResourceFactory resourceFactory, IJsonApiRequest request, IJsonApiDeserializer deserializer) - : base(resourceFactory, deserializer, request) + public SetRelationshipProcessor(ISetRelationshipService service) { _service = service ?? throw new ArgumentNullException(nameof(service)); - _request = request ?? throw new ArgumentNullException(nameof(request)); } /// - public async Task ProcessAsync(AtomicOperationObject operation, CancellationToken cancellationToken) + public async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { if (operation == null) throw new ArgumentNullException(nameof(operation)); - var primaryId = GetPrimaryId(operation.Ref.Id); - object relationshipValueToAssign = null; + var primaryId = (TId) operation.Resource.GetTypedId(); + object secondaryResourceIds = GetSecondaryResourceIdOrIds(operation); - if (operation.SingleData != null) - { - relationshipValueToAssign = _deserializer.CreateResourceFromObject(operation.SingleData); - } + await _service.SetRelationshipAsync(primaryId, operation.Request.Relationship.PublicName, secondaryResourceIds, cancellationToken); - if (operation.ManyData != null) - { - relationshipValueToAssign = GetSecondaryResourceIds(operation); - } - - await _service.SetRelationshipAsync(primaryId, _request.Relationship.PublicName, relationshipValueToAssign, cancellationToken); - - return new AtomicResultObject(); + return null; } } @@ -61,9 +44,8 @@ public class SetRelationshipProcessor : SetRelationshipProcessor, IUpdateProcessor where TResource : class, IIdentifiable { - public SetRelationshipProcessor(ISetRelationshipService service, - IResourceFactory resourceFactory, IJsonApiRequest request, IJsonApiDeserializer deserializer) - : base(service, resourceFactory, request, deserializer) + public SetRelationshipProcessor(ISetRelationshipService service) + : base(service) { } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs index 559071dbe7..dd2da50f11 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs @@ -1,13 +1,7 @@ using System; -using System.Net; using System.Threading; using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Building; -using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCore.Services; namespace JsonApiDotNetCore.AtomicOperations.Processors @@ -22,53 +16,19 @@ public class UpdateProcessor : IUpdateProcessor where TResource : class, IIdentifiable { private readonly IUpdateService _service; - private readonly IJsonApiDeserializer _deserializer; - private readonly IResourceObjectBuilder _resourceObjectBuilder; - private readonly IResourceContextProvider _resourceContextProvider; - public UpdateProcessor(IUpdateService service, IJsonApiDeserializer deserializer, - IResourceObjectBuilder resourceObjectBuilder, IResourceContextProvider resourceContextProvider) + public UpdateProcessor(IUpdateService service) { _service = service ?? throw new ArgumentNullException(nameof(service)); - _deserializer = deserializer ?? throw new ArgumentNullException(nameof(deserializer)); - _resourceObjectBuilder = resourceObjectBuilder ?? throw new ArgumentNullException(nameof(resourceObjectBuilder)); - _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); } /// - public async Task ProcessAsync(AtomicOperationObject operation, CancellationToken cancellationToken) + public async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { if (operation == null) throw new ArgumentNullException(nameof(operation)); - if (operation.SingleData == null) - { - throw new InvalidOperationException("TODO: Expected data element. Can we ever get here?"); - } - - if (operation.SingleData.Id == null) - { - throw new JsonApiException(new Error(HttpStatusCode.BadRequest) - { - Title = "The data.id element is required for replace operations." - }); - } - - var model = (TResource) _deserializer.CreateResourceFromObject(operation.SingleData); - - var result = await _service.UpdateAsync(model.Id, model, cancellationToken); - - ResourceObject data = null; - - if (result != null) - { - ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(operation.SingleData.Type); - data = _resourceObjectBuilder.Build(result, resourceContext.Attributes, resourceContext.Relationships); - } - - return new AtomicResultObject - { - Data = data - }; + var resource = (TResource) operation.Resource; + return await _service.UpdateAsync(resource.Id, resource, cancellationToken); } } @@ -81,9 +41,8 @@ public class UpdateProcessor : UpdateProcessor, IUpdateProcessor where TResource : class, IIdentifiable { - public UpdateProcessor(IUpdateService service, IJsonApiDeserializer deserializer, - IResourceObjectBuilder resourceObjectBuilder, IResourceContextProvider resourceContextProvider) - : base(service, deserializer, resourceObjectBuilder, resourceContextProvider) + public UpdateProcessor(IUpdateService service) + : base(service) { } } diff --git a/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs b/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs index 301674c1aa..e9502f4405 100644 --- a/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs @@ -1,8 +1,7 @@ using System; -using System.Net; using JsonApiDotNetCore.AtomicOperations.Processors; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; namespace JsonApiDotNetCore.Configuration { @@ -20,75 +19,38 @@ public AtomicOperationProcessorResolver(IGenericServiceFactory genericServiceFac } /// - public IAtomicOperationProcessor ResolveProcessor(AtomicOperationObject operation) + public IAtomicOperationProcessor ResolveProcessor(OperationContainer operation) { if (operation == null) throw new ArgumentNullException(nameof(operation)); // TODO: @OPS: How about processors with a single type argument? - if (operation.Ref?.Relationship != null) + switch (operation.Kind) { - switch (operation.Code) - { - case AtomicOperationCode.Add: - { - return Resolve(operation, typeof(IAddToRelationshipProcessor<,>)); - } - case AtomicOperationCode.Update: - { - return Resolve(operation, typeof(ISetRelationshipProcessor<,>)); - } - case AtomicOperationCode.Remove: - { - return Resolve(operation, typeof(IRemoveFromRelationshipProcessor<,>)); - } - } - } - - switch (operation.Code) - { - case AtomicOperationCode.Add: - { + case OperationKind.CreateResource: return Resolve(operation, typeof(ICreateProcessor<,>)); - } - case AtomicOperationCode.Update: - { + case OperationKind.UpdateResource: return Resolve(operation, typeof(IUpdateProcessor<,>)); - } - case AtomicOperationCode.Remove: - { + case OperationKind.DeleteResource: return Resolve(operation, typeof(IDeleteProcessor<,>)); - } + case OperationKind.SetRelationship: + return Resolve(operation, typeof(ISetRelationshipProcessor<,>)); + case OperationKind.AddToRelationship: + return Resolve(operation, typeof(IAddToRelationshipProcessor<,>)); + case OperationKind.RemoveFromRelationship: + return Resolve(operation, typeof(IRemoveFromRelationshipProcessor<,>)); + default: + throw new NotSupportedException($"Unknown operation kind '{operation.Kind}'."); } - - throw new InvalidOperationException($"Operation code '{operation.Code}' is invalid."); } - private IAtomicOperationProcessor Resolve(AtomicOperationObject atomicOperationObject, Type processorInterface) + private IAtomicOperationProcessor Resolve(OperationContainer operation, Type processorInterface) { - var resourceName = atomicOperationObject.GetResourceTypeName(); - var resourceContext = GetResourceContext(resourceName); + var resourceContext = _resourceContextProvider.GetResourceContext(operation.Resource.GetType()); return _genericServiceFactory.Get(processorInterface, resourceContext.ResourceType, resourceContext.IdentityType ); } - - private ResourceContext GetResourceContext(string resourceName) - { - var resourceContext = _resourceContextProvider.GetResourceContext(resourceName); - if (resourceContext == null) - { - // TODO: @OPS: Should have validated this earlier in the call stack. - - throw new JsonApiException(new Error(HttpStatusCode.BadRequest) - { - Title = "Unsupported resource type.", - Detail = $"This API does not expose a resource of type '{resourceName}'." - }); - } - - return resourceContext; - } } } diff --git a/src/JsonApiDotNetCore/Configuration/IAtomicOperationProcessorResolver.cs b/src/JsonApiDotNetCore/Configuration/IAtomicOperationProcessorResolver.cs index 0a9dbd2f44..6931cd6fbb 100644 --- a/src/JsonApiDotNetCore/Configuration/IAtomicOperationProcessorResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/IAtomicOperationProcessorResolver.cs @@ -1,16 +1,16 @@ using JsonApiDotNetCore.AtomicOperations.Processors; -using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Resources; namespace JsonApiDotNetCore.Configuration { /// - /// Used to resolve a compatible at runtime, based on the operation code. + /// Used to resolve a compatible at runtime, based on the operation kind. /// public interface IAtomicOperationProcessorResolver { /// /// Resolves a compatible . /// - IAtomicOperationProcessor ResolveProcessor(AtomicOperationObject operation); + IAtomicOperationProcessor ResolveProcessor(OperationContainer operation); } } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs index 4f1bd83fc6..c866a831f0 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs @@ -2,7 +2,6 @@ using System.Linq; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Microsoft.Extensions.DependencyInjection; @@ -38,7 +37,7 @@ public bool ShouldValidateEntry(ValidationEntry entry, ValidationEntry parentEnt } var httpContextAccessor = _serviceProvider.GetRequiredService(); - if (httpContextAccessor.HttpContext.Request.Method == HttpMethods.Patch || request.OperationCode == AtomicOperationCode.Update) + if (httpContextAccessor.HttpContext.Request.Method == HttpMethods.Patch || request.OperationKind == OperationKind.UpdateResource) { var targetedFields = _serviceProvider.GetRequiredService(); return IsFieldTargeted(entry, targetedFields); diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs index c9a818a596..3cb32504aa 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs @@ -1,11 +1,13 @@ 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.Serialization.Objects; +using JsonApiDotNetCore.Resources; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -19,27 +21,31 @@ public abstract class BaseJsonApiAtomicOperationsController : CoreJsonApiControl { private readonly IJsonApiOptions _options; private readonly IAtomicOperationsProcessor _processor; + private readonly IJsonApiRequest _request; + private readonly ITargetedFields _targetedFields; private readonly TraceLogWriter _traceWriter; protected BaseJsonApiAtomicOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IAtomicOperationsProcessor processor) + IAtomicOperationsProcessor 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 document with operations and returns their results. + /// Atomically processes a list of operations and returns a list of results. /// If processing fails, all changes are reverted. - /// If processing succeeds and none of the operations contain data, HTTP 201 is returned instead 200. + /// 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] AtomicOperationsDocument document, + public virtual async Task PostOperationsAsync([FromBody] IList operations, CancellationToken cancellationToken) { - _traceWriter.LogMethodStart(new {document}); + _traceWriter.LogMethodStart(new {operations}); + if (operations == null) throw new ArgumentNullException(nameof(operations)); - if (document == null) + if (_options.ValidateModelState) { - // TODO: @OPS: Should throw NullReferenceException here, but catch this error higher up the call stack (JsonApiReader). - return new StatusCodeResult(422); + ValidateModelState(operations); } - var results = await _processor.ProcessAsync(document.Operations, cancellationToken); + var results = await _processor.ProcessAsync(operations, cancellationToken); + return results.Any(result => result != null) ? (IActionResult) Ok(results) : NoContent(); + } + + protected virtual void ValidateModelState(IEnumerable operations) + { + var violations = new List(); - if (results.Any(result => result.Data != null)) + int index = 0; + foreach (var operation in operations) { - return Ok(new AtomicOperationsDocument + if (operation.Kind == OperationKind.CreateResource || operation.Kind == OperationKind.UpdateResource) { - Results = results - }); + _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++; } - return NoContent(); + if (violations.Any()) + { + var namingStrategy = _options.SerializerContractResolver.NamingStrategy; + throw new InvalidModelStateException(violations, _options.IncludeExceptionStackTraceInErrors, namingStrategy); + } } } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiAtomicOperationsController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiAtomicOperationsController.cs index 8cf3f90a96..ffe71facc8 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiAtomicOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiAtomicOperationsController.cs @@ -1,8 +1,10 @@ +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -22,17 +24,17 @@ namespace JsonApiDotNetCore.Controllers public abstract class JsonApiAtomicOperationsController : BaseJsonApiAtomicOperationsController { protected JsonApiAtomicOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IAtomicOperationsProcessor processor) - : base(options, loggerFactory, processor) + IAtomicOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, loggerFactory, processor, request, targetedFields) { } /// [HttpPost] - public override async Task PostOperationsAsync([FromBody] AtomicOperationsDocument document, + public override async Task PostOperationsAsync([FromBody] IList operations, CancellationToken cancellationToken) { - return await base.PostOperationsAsync(document, cancellationToken); + return await base.PostOperationsAsync(operations, cancellationToken); } } } diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs index 52450df3fe..39677ebbe7 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs @@ -1,6 +1,5 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.Middleware { @@ -62,6 +61,11 @@ public interface IJsonApiRequest /// /// In case of an atomic:operations request, this indicates the operation currently being processed. /// - AtomicOperationCode? OperationCode { get; } + OperationKind? OperationKind { get; } + + /// + /// Performs a shallow copy. + /// + void CopyFrom(IJsonApiRequest other); } } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs index 3263137fb3..28ed7f879c 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs @@ -1,6 +1,6 @@ +using System; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.Middleware { @@ -32,6 +32,22 @@ public sealed class JsonApiRequest : IJsonApiRequest public bool IsReadOnly { get; set; } /// - public AtomicOperationCode? OperationCode { get; set; } + public OperationKind? OperationKind { 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; + } } } diff --git a/src/JsonApiDotNetCore/Middleware/OperationKind.cs b/src/JsonApiDotNetCore/Middleware/OperationKind.cs new file mode 100644 index 0000000000..3cc02d92c3 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/OperationKind.cs @@ -0,0 +1,15 @@ +namespace JsonApiDotNetCore.Middleware +{ + /// + /// Lists the functional operations from an atomic:operations request. + /// + public enum OperationKind + { + CreateResource, + UpdateResource, + DeleteResource, + SetRelationship, + AddToRelationship, + RemoveFromRelationship + } +} diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 903e198184..beeb921a70 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using System.Transactions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; 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..bc556f4911 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/OperationContainer.cs @@ -0,0 +1,25 @@ +using System; +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)); + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs index 2e54de15af..1bee7b5f58 100644 --- a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Building; using JsonApiDotNetCore.Serialization.Objects; @@ -11,21 +13,24 @@ namespace JsonApiDotNetCore.Serialization /// public sealed class AtomicOperationsResponseSerializer : BaseSerializer, IJsonApiSerializer { + private readonly IResourceContextProvider _resourceContextProvider; private readonly IJsonApiOptions _options; public string ContentType { get; } = HeaderConstants.AtomicOperationsMediaType; - public AtomicOperationsResponseSerializer(IResourceObjectBuilder resourceObjectBuilder, IJsonApiOptions options) + public AtomicOperationsResponseSerializer(IResourceObjectBuilder resourceObjectBuilder, + IResourceContextProvider resourceContextProvider, IJsonApiOptions options) : base(resourceObjectBuilder) { + _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); _options = options ?? throw new ArgumentNullException(nameof(options)); } public string Serialize(object content) { - if (content is AtomicOperationsDocument atomicOperationsDocument) + if (content is IList resources) { - return SerializeOperationsDocument(atomicOperationsDocument); + return SerializeOperationsDocument(resources); } if (content is ErrorDocument errorDocument) @@ -36,9 +41,30 @@ public string Serialize(object content) throw new InvalidOperationException("Data being returned must be errors or an atomic:operations document."); } - private string SerializeOperationsDocument(AtomicOperationsDocument content) + private string SerializeOperationsDocument(IEnumerable resources) { - return SerializeObject(content, _options.SerializerSettings); + var document = new AtomicOperationsDocument + { + Results = new List() + }; + + foreach (IIdentifiable resource in resources) + { + ResourceObject resourceObject = null; + + if (resource != null) + { + var resourceContext = _resourceContextProvider.GetResourceContext(resource.GetType()); + resourceObject = ResourceObjectBuilder.Build(resource, resourceContext.Attributes, resourceContext.Relationships); + } + + document.Results.Add(new AtomicResultObject + { + Data = resourceObject + }); + } + + return SerializeObject(document, _options.SerializerSettings); } private string SerializeErrorDocument(ErrorDocument errorDocument) diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index ee0180e302..95d3928ed7 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 abstract bool AllowLocalIds { get; } + protected BaseDeserializer(IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory) { ResourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); @@ -53,13 +55,13 @@ protected object DeserializeBody(string body) if (Document.IsManyData) { return Document.ManyData - .Select(data => ParseResourceObject(data, false)) + .Select(ParseResourceObject) .ToHashSet(IdentifiableComparer.Instance); } if (Document.SingleData != null) { - return ParseResourceObject(Document.SingleData, false); + return ParseResourceObject(Document.SingleData); } } @@ -152,13 +154,13 @@ protected JToken LoadJToken(string body) /// and sets its attributes and relationships. /// /// The parsed resource. - protected IIdentifiable ParseResourceObject(ResourceObject data, bool allowLocalIds) + protected IIdentifiable ParseResourceObject(ResourceObject data) { AssertHasType(data, null); - if (!allowLocalIds) + if (!AllowLocalIds) { - AssertHasNoLocalId(data); + AssertHasNoLid(data); } var resourceContext = GetExistingResourceContext(data.Type); @@ -168,7 +170,11 @@ protected IIdentifiable ParseResourceObject(ResourceObject data, bool allowLocal resource = SetRelationships(resource, data.Relationships, resourceContext.Relationships); if (data.Id != null) + { resource.StringId = data.Id; + } + + resource.LocalId = data.Lid; return resource; } @@ -234,13 +240,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; } @@ -260,20 +267,36 @@ private void AssertHasType(ResourceIdentifierObject resourceIdentifierObject, Re } } - private void AssertHasNoLocalId(ResourceIdentifierObject resourceIdentifierObject) + private void AssertHasIdOrLid(ResourceIdentifierObject resourceIdentifierObject, RelationshipAttribute relationship) { - if (resourceIdentifierObject.Lid != null) + if (AllowLocalIds) { - throw new JsonApiSerializationException("Local IDs cannot be used at this endpoint.", null); + bool hasNone = resourceIdentifierObject.Id == null && resourceIdentifierObject.Lid == null; + bool hasBoth = resourceIdentifierObject.Id != null && resourceIdentifierObject.Lid != null; + + if (hasNone || hasBoth) + { + throw new JsonApiSerializationException("TODO: Request body must include 'id' or 'lid' element.", + $"Expected 'id' or 'lid' element in '{relationship.PublicName}' relationship."); + } + } + else + { + if (resourceIdentifierObject.Id == null) + { + throw new JsonApiSerializationException("Request body must include 'id' element.", + $"Expected 'id' element in '{relationship.PublicName}' relationship."); + } + + 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); } } 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/Client/Internal/ResponseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/ResponseDeserializer.cs index 91b6a0392c..ec720fa35e 100644 --- a/src/JsonApiDotNetCore/Serialization/Client/Internal/ResponseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/Client/Internal/ResponseDeserializer.cs @@ -13,6 +13,8 @@ namespace JsonApiDotNetCore.Serialization.Client.Internal /// public class ResponseDeserializer : BaseDeserializer, IResponseDeserializer { + protected override bool AllowLocalIds => false; + public ResponseDeserializer(IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory) : base(resourceContextProvider, resourceFactory) { } /// diff --git a/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs index a1bee9b7d8..406ec149b7 100644 --- a/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs @@ -1,4 +1,3 @@ -using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.Serialization @@ -14,11 +13,5 @@ public interface IJsonApiDeserializer /// /// The JSON to be deserialized. object DeserializeDocument(string body); - - /// - /// Creates an instance of the referenced type in - /// and sets its attributes and relationships - /// - IIdentifiable CreateResourceFromObject(ResourceObject data); } } diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index d21c18d1ad..e5e29b12f6 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; @@ -23,6 +24,8 @@ public class RequestDeserializer : BaseDeserializer, IJsonApiDeserializer private readonly IHttpContextAccessor _httpContextAccessor; private readonly IJsonApiRequest _request; + protected override bool AllowLocalIds => _request.Kind == EndpointKind.AtomicOperations; + public RequestDeserializer( IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory, @@ -63,6 +66,8 @@ private object DeserializeOperationsDocument(string body) JToken bodyToken = LoadJToken(body); var document = bodyToken.ToObject(); + var operations = new List(); + if (document?.Operations == null || !document.Operations.Any()) { throw new JsonApiException(new Error(HttpStatusCode.BadRequest) @@ -74,14 +79,18 @@ private object DeserializeOperationsDocument(string body) int index = 0; foreach (var operation in document.Operations) { - ValidateOperation(operation, index); + var container = DeserializeOperation(operation, index); + operations.Add(container); + index++; } - return document; + return operations; } - private void ValidateOperation(AtomicOperationObject operation, int index) + // TODO: Cleanup code. + + private OperationContainer DeserializeOperation(AtomicOperationObject operation, int index) { if (operation.Href != null) { @@ -223,13 +232,122 @@ private void ValidateOperation(AtomicOperationObject operation, int index) } } } + + return ToOperationContainer(operation); } - public IIdentifiable CreateResourceFromObject(ResourceObject data) + private OperationContainer ToOperationContainer(AtomicOperationObject operation) { - if (data == null) throw new ArgumentNullException(nameof(data)); + var operationKind = GetOperationKind(operation); + + var resourceName = operation.GetResourceTypeName(); + var primaryResourceContext = ResourceContextProvider.GetResourceContext(resourceName); + + _targetedFields.Attributes.Clear(); + _targetedFields.Relationships.Clear(); + + IIdentifiable resource; + + switch (operationKind) + { + case OperationKind.CreateResource: + case OperationKind.UpdateResource: + { + resource = ParseResourceObject(operation.SingleData); + break; + } + case OperationKind.DeleteResource: + case OperationKind.SetRelationship: + case OperationKind.AddToRelationship: + case OperationKind.RemoveFromRelationship: + { + resource = ResourceFactory.CreateInstance(primaryResourceContext.ResourceType); + resource.StringId = operation.Ref.Id; + resource.LocalId = operation.Ref.Lid; + break; + } + default: + { + throw new NotSupportedException($"Unknown operation kind '{operationKind}'."); + } + } + + var request = new JsonApiRequest + { + Kind = EndpointKind.AtomicOperations, + OperationKind = operationKind, + BasePath = "TODO: Set this...", + PrimaryResource = primaryResourceContext + }; + + if (operation.Ref != null) + { + request.PrimaryId = operation.Ref.Id; + + if (operation.Ref.Relationship != null) + { + var relationship = primaryResourceContext.Relationships.SingleOrDefault(r => r.PublicName == operation.Ref.Relationship); + if (relationship == null) + { + throw new InvalidOperationException("TODO: @OPS: Relationship does not exist."); + } - return ParseResourceObject(data, true); + var secondaryResourceContext = ResourceContextProvider.GetResourceContext(relationship.RightType); + if (secondaryResourceContext == null) + { + throw new InvalidOperationException("TODO: @OPS: Secondary resource type does not exist."); + } + + request.SecondaryResource = secondaryResourceContext; + request.Relationship = relationship; + request.IsCollection = relationship is HasManyAttribute; + + _targetedFields.Relationships.Add(relationship); + + if (operation.SingleData != null) + { + var rightResource = ParseResourceObject(operation.SingleData); + relationship.SetValue(resource, rightResource); + } + else if (operation.ManyData != null) + { + var secondaryResources = operation.ManyData.Select(ParseResourceObject).ToArray(); + var rightResources = TypeHelper.CopyToTypedCollection(secondaryResources, relationship.Property.PropertyType); + relationship.SetValue(resource, rightResources); + } + } + } + + var targetedFields = new TargetedFields + { + Attributes = _targetedFields.Attributes.ToHashSet(), + Relationships = _targetedFields.Relationships.ToHashSet() + }; + + return new OperationContainer(operationKind, resource, targetedFields, request); + } + + private static OperationKind GetOperationKind(AtomicOperationObject operation) + { + switch (operation.Code) + { + case AtomicOperationCode.Add: + { + return operation.Ref != null ? OperationKind.AddToRelationship : OperationKind.CreateResource; + } + case AtomicOperationCode.Update: + { + return operation.Ref?.Relationship != null ? OperationKind.SetRelationship : OperationKind.UpdateResource; + } + case AtomicOperationCode.Remove: + { + return operation.Ref.Relationship != null ? OperationKind.RemoveFromRelationship : OperationKind.DeleteResource; + } + default: + { + throw new NotSupportedException($"Unknown operation code '{operation.Code}'."); + } + } } private void AssertResourceIdIsNotTargeted() diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index 5364803fcf..94b3da2133 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -319,7 +319,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [Fact(Skip = "TODO: Make this test work")] + [Fact(Skip = "TODO: @OPS: Make this test work")] public async Task Cannot_create_resource_with_client_generated_ID() { // Arrange @@ -425,7 +425,7 @@ public async Task Cannot_create_resource_for_ref_element() responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); } - [Fact(Skip = "TODO: Make this test work")] + [Fact(Skip = "TODO: @OPS: Make this test work")] public async Task Cannot_create_resource_for_missing_type() { // Arrange @@ -461,7 +461,7 @@ public async Task Cannot_create_resource_for_missing_type() responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); } - [Fact(Skip = "TODO: Make this test work")] + [Fact(Skip = "TODO: @OPS: Make this test work")] public async Task Cannot_create_resource_for_unknown_type() { // Arrange @@ -495,7 +495,7 @@ public async Task Cannot_create_resource_for_unknown_type() responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); } - [Fact(Skip = "TODO: Make this test work")] + [Fact(Skip = "TODO: @OPS: Make this test work")] public async Task Cannot_create_resource_attribute_with_blocked_capability() { // Arrange @@ -533,7 +533,7 @@ public async Task Cannot_create_resource_attribute_with_blocked_capability() responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); } - [Fact(Skip = "TODO: Make this test work")] + [Fact(Skip = "TODO: @OPS: Make this test work")] public async Task Cannot_create_resource_with_incompatible_attribute_value() { // Arrange @@ -610,12 +610,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => type = "lyrics", id = existingLyric.StringId } - }, + }, ownedBy = new { data = new { - type="recordCompanies", + type = "recordCompanies", id = existingCompany.StringId } }, diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs index e900624ace..40a4e4fd75 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs @@ -1441,7 +1441,8 @@ public async Task Can_remove_from_OneToMany_relationship_using_local_ID() var existingPerformer = _fakers.Performer.Generate(); var newTrackTitle = _fakers.MusicTrack.Generate().Title; - var newArtistName = _fakers.Performer.Generate().ArtistName; + var newArtistName1 = _fakers.Performer.Generate().ArtistName; + var newArtistName2 = _fakers.Performer.Generate().ArtistName; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1450,7 +1451,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); const string trackLocalId = "track-1"; - const string performerLocalId = "performer-1"; + const string performerLocalId1 = "performer-1"; + const string performerLocalId2 = "performer-2"; var requestBody = new { @@ -1462,10 +1464,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "performers", - lid = performerLocalId, + lid = performerLocalId1, attributes = new { - artistName = newArtistName + artistName = newArtistName1 + } + } + }, + new + { + op = "add", + data = new + { + type = "performers", + lid = performerLocalId2, + attributes = new + { + artistName = newArtistName2 } } }, @@ -1494,7 +1509,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "performers", - lid = performerLocalId + lid = performerLocalId1 + }, + new + { + type = "performers", + lid = performerLocalId2 } } } @@ -1515,7 +1535,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => new { type = "performers", - lid = performerLocalId + lid = performerLocalId1 + }, + new + { + type = "performers", + lid = performerLocalId2 } } } @@ -1530,21 +1555,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.Should().HaveCount(3); + 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(newArtistName); + responseDocument.Results[0].SingleData.Attributes["artistName"].Should().Be(newArtistName1); responseDocument.Results[1].SingleData.Should().NotBeNull(); - responseDocument.Results[1].SingleData.Type.Should().Be("musicTracks"); + responseDocument.Results[1].SingleData.Type.Should().Be("performers"); responseDocument.Results[1].SingleData.Lid.Should().BeNull(); - responseDocument.Results[1].SingleData.Attributes["title"].Should().Be(newTrackTitle); + responseDocument.Results[1].SingleData.Attributes["artistName"].Should().Be(newArtistName2); - responseDocument.Results[2].Data.Should().BeNull(); + 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); - var newTrackId = Guid.Parse(responseDocument.Results[1].SingleData.Id); + responseDocument.Results[3].Data.Should().BeNull(); + + var newTrackId = Guid.Parse(responseDocument.Results[2].SingleData.Id); await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs index acfdd83fc9..022330f7f5 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs @@ -110,9 +110,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - // TODO: Can_process_empty_operations_array - // TODO: Cannot_process_operations_for_missing_request_body - // TODO: Cannot_process_operations_for_broken_JSON_request_body - // TODO: Cannot_process_operations_for_unknown_operation_code + // TODO: @OPS: Cannot_process_empty_operations_array + // TODO: @OPS: Cannot_process_operations_for_missing_request_body + // TODO: @OPS: Cannot_process_operations_for_broken_JSON_request_body + // TODO: @OPS: Cannot_process_operations_for_unknown_operation_code } } 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/Serialization/DeserializerTestsSetup.cs b/test/UnitTests/Serialization/DeserializerTestsSetup.cs index 2f8d4a053f..b09ce1d184 100644 --- a/test/UnitTests/Serialization/DeserializerTestsSetup.cs +++ b/test/UnitTests/Serialization/DeserializerTestsSetup.cs @@ -20,6 +20,8 @@ public DeserializerTestsSetup() } protected sealed class TestDeserializer : BaseDeserializer { + protected override bool AllowLocalIds => false; + public TestDeserializer(IResourceGraph resourceGraph, IResourceFactory resourceFactory) : base(resourceGraph, resourceFactory) { } public object Deserialize(string body) From 175215c26a4957b3e98e2ceda2e6c231187c7c97 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 7 Jan 2021 10:39:58 +0100 Subject: [PATCH 039/123] Fixed skipped tests --- .../Controllers/AtomicOperationsController.cs | 1 + .../BaseJsonApiAtomicOperationsController.cs | 19 ++ ...ourceIdInPostRequestNotAllowedException.cs | 6 +- .../Middleware/OperationKind.cs | 1 + .../Middleware/OperationKindExtensions.cs | 17 ++ .../Serialization/BaseDeserializer.cs | 34 +-- .../Client/Internal/ResponseDeserializer.cs | 2 - .../Serialization/RequestDeserializer.cs | 197 +++++++++--------- .../Creating/AtomicCreateResourceTests.cs | 16 +- .../Serialization/DeserializerTestsSetup.cs | 2 - 10 files changed, 174 insertions(+), 121 deletions(-) create mode 100644 src/JsonApiDotNetCore/Middleware/OperationKindExtensions.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/AtomicOperationsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/AtomicOperationsController.cs index 43d8aaff62..b2ae122808 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/AtomicOperationsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/AtomicOperationsController.cs @@ -9,6 +9,7 @@ namespace JsonApiDotNetCoreExample.Controllers { + // TODO: @OPS: Apply route based on configured namespace. [DisableRoutingConvention, Route("/api/v1/operations")] public class AtomicOperationsController : JsonApiAtomicOperationsController { diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs index 3cb32504aa..b734c4113c 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs @@ -101,6 +101,8 @@ public virtual async Task PostOperationsAsync([FromBody] IList operat throw new InvalidModelStateException(violations, _options.IncludeExceptionStackTraceInErrors, namingStrategy); } } + + 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 ResourceIdInPostRequestNotAllowedException(index); + } + + index++; + } + } + } } } diff --git a/src/JsonApiDotNetCore/Errors/ResourceIdInPostRequestNotAllowedException.cs b/src/JsonApiDotNetCore/Errors/ResourceIdInPostRequestNotAllowedException.cs index 6b621faa6f..257aeb81a4 100644 --- a/src/JsonApiDotNetCore/Errors/ResourceIdInPostRequestNotAllowedException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourceIdInPostRequestNotAllowedException.cs @@ -8,13 +8,15 @@ namespace JsonApiDotNetCore.Errors /// public sealed class ResourceIdInPostRequestNotAllowedException : JsonApiException { - public ResourceIdInPostRequestNotAllowedException() + public ResourceIdInPostRequestNotAllowedException(int? atomicOperationIndex = null) : base(new Error(HttpStatusCode.Forbidden) { Title = "Specifying the resource ID in POST requests is not allowed.", Source = { - Pointer = "/data/id" + Pointer = atomicOperationIndex != null + ? $"/atomic:operations[{atomicOperationIndex}]/data/id" + : "/data/id" } }) { diff --git a/src/JsonApiDotNetCore/Middleware/OperationKind.cs b/src/JsonApiDotNetCore/Middleware/OperationKind.cs index 3cc02d92c3..9859b952b1 100644 --- a/src/JsonApiDotNetCore/Middleware/OperationKind.cs +++ b/src/JsonApiDotNetCore/Middleware/OperationKind.cs @@ -2,6 +2,7 @@ namespace JsonApiDotNetCore.Middleware { /// /// Lists the functional operations from an atomic:operations request. + /// See also . /// public enum OperationKind { diff --git a/src/JsonApiDotNetCore/Middleware/OperationKindExtensions.cs b/src/JsonApiDotNetCore/Middleware/OperationKindExtensions.cs new file mode 100644 index 0000000000..f21e8711fb --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/OperationKindExtensions.cs @@ -0,0 +1,17 @@ +namespace JsonApiDotNetCore.Middleware +{ + public static class OperationKindExtensions + { + public static bool IsRelationship(this OperationKind kind) + { + return kind == OperationKind.SetRelationship || kind == OperationKind.AddToRelationship || + kind == OperationKind.RemoveFromRelationship; + } + + public static bool IsResource(this OperationKind kind) + { + return kind == OperationKind.CreateResource || kind == OperationKind.UpdateResource || + kind == OperationKind.DeleteResource; + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index 95d3928ed7..7f04c77942 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -22,7 +22,7 @@ public abstract class BaseDeserializer protected IResourceFactory ResourceFactory { get; } protected Document Document { get; set; } - protected abstract bool AllowLocalIds { get; } + protected int? AtomicOperationIndex { get; set; } protected BaseDeserializer(IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory) { @@ -88,7 +88,8 @@ protected IIdentifiable SetAttributes(IIdentifiable resource, IDictionary public class ResponseDeserializer : BaseDeserializer, IResponseDeserializer { - protected override bool AllowLocalIds => false; - public ResponseDeserializer(IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory) : base(resourceContextProvider, resourceFactory) { } /// diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index e5e29b12f6..37671c40c3 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -24,8 +24,6 @@ public class RequestDeserializer : BaseDeserializer, IJsonApiDeserializer private readonly IHttpContextAccessor _httpContextAccessor; private readonly IJsonApiRequest _request; - protected override bool AllowLocalIds => _request.Kind == EndpointKind.AtomicOperations; - public RequestDeserializer( IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory, @@ -76,13 +74,13 @@ private object DeserializeOperationsDocument(string body) }); } - int index = 0; + AtomicOperationIndex = 0; foreach (var operation in document.Operations) { - var container = DeserializeOperation(operation, index); + var container = DeserializeOperation(operation); operations.Add(container); - index++; + AtomicOperationIndex++; } return operations; @@ -90,43 +88,38 @@ private object DeserializeOperationsDocument(string body) // TODO: Cleanup code. - private OperationContainer DeserializeOperation(AtomicOperationObject operation, int index) + private OperationContainer DeserializeOperation(AtomicOperationObject operation) { if (operation.Href != null) { throw new JsonApiSerializationException("Usage of the 'href' element is not supported.", null, - atomicOperationIndex: index); + atomicOperationIndex: AtomicOperationIndex); } - if (operation.Code == AtomicOperationCode.Remove) + var kind = GetOperationKind(operation); + + if (kind == OperationKind.AddToRelationship && operation.Ref.Relationship == null) { - if (operation.Ref == null) - { - throw new JsonApiSerializationException("The 'ref' element is required.", null, - atomicOperationIndex: index); - } + throw new JsonApiSerializationException("The 'ref.relationship' element is required.", null, + atomicOperationIndex: AtomicOperationIndex); } + RelationshipAttribute relationship = null; + if (operation.Ref != null) { - if (operation.Code == AtomicOperationCode.Add && operation.Ref.Relationship == null) - { - throw new JsonApiSerializationException("The 'ref.relationship' element is required.", null, - atomicOperationIndex: index); - } - if (operation.Ref.Type == null) { throw new JsonApiSerializationException("The 'ref.type' element is required.", null, - atomicOperationIndex: index); + atomicOperationIndex: AtomicOperationIndex); } - var resourceContext = GetExistingResourceContext(operation.Ref.Type, index); + var resourceContext = GetExistingResourceContext(operation.Ref.Type); if ((operation.Ref.Id == null && operation.Ref.Lid == null) || (operation.Ref.Id != null && operation.Ref.Lid != null)) { throw new JsonApiSerializationException("The 'ref.id' or 'ref.lid' element is required.", null, - atomicOperationIndex: index); + atomicOperationIndex: AtomicOperationIndex); } if (operation.Ref.Id != null) @@ -137,27 +130,27 @@ private OperationContainer DeserializeOperation(AtomicOperationObject operation, } catch (FormatException exception) { - throw new JsonApiSerializationException(null, exception.Message, null, index); + throw new JsonApiSerializationException(null, exception.Message, null, AtomicOperationIndex); } } if (operation.Ref.Relationship != null) { - var relationship = resourceContext.Relationships.FirstOrDefault(r => r.PublicName == operation.Ref.Relationship); + relationship = resourceContext.Relationships.FirstOrDefault(r => r.PublicName == operation.Ref.Relationship); if (relationship == null) { throw new JsonApiSerializationException( "The referenced relationship does not exist.", $"Resource of type '{operation.Ref.Type}' does not contain a relationship named '{operation.Ref.Relationship}'.", - atomicOperationIndex: index); + atomicOperationIndex: AtomicOperationIndex); } - if (operation.Code != AtomicOperationCode.Update && relationship is HasOneAttribute) + if ((kind == OperationKind.AddToRelationship || kind == OperationKind.RemoveFromRelationship) && 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: index); + atomicOperationIndex: AtomicOperationIndex); } if (relationship is HasOneAttribute && operation.ManyData != null) @@ -165,7 +158,7 @@ private OperationContainer DeserializeOperation(AtomicOperationObject operation, throw new JsonApiSerializationException( "Expected single data element for to-one relationship.", $"Expected single data element for '{relationship.PublicName}' relationship.", - atomicOperationIndex: index); + atomicOperationIndex: AtomicOperationIndex); } if (relationship is HasManyAttribute && operation.ManyData == null) @@ -173,82 +166,93 @@ private OperationContainer DeserializeOperation(AtomicOperationObject operation, throw new JsonApiSerializationException( "Expected data[] element for to-many relationship.", $"Expected data[] element for '{relationship.PublicName}' relationship.", - atomicOperationIndex: index); + atomicOperationIndex: AtomicOperationIndex); + } + } + } + + if (operation.ManyData != null) + { + foreach (var resourceObject in operation.ManyData) + { + if (resourceObject.Type == null) + { + throw new JsonApiSerializationException("The 'data[].type' element is required.", null, + atomicOperationIndex: AtomicOperationIndex); + } + + if ((resourceObject.Id == null && resourceObject.Lid == null) || (resourceObject.Id != null && resourceObject.Lid != null)) + { + throw new JsonApiSerializationException("The 'data[].id' or 'data[].lid' element is required.", null, + atomicOperationIndex: AtomicOperationIndex); + } + + if (relationship == null) + { + throw new Exception("TODO: @OPS: This happens when sending an array for CreateResource/UpdateResource."); } - if (operation.ManyData != null) + var rightResourceContext = GetExistingResourceContext(resourceObject.Type); + if (!rightResourceContext.ResourceType.IsAssignableFrom(relationship.RightType)) { - foreach (var resourceObject in operation.ManyData) - { - if (resourceObject.Type == null) - { - throw new JsonApiSerializationException("The 'data[].type' element is required.", null, - atomicOperationIndex: index); - } - - if ((resourceObject.Id == null && resourceObject.Lid == null) || (resourceObject.Id != null && resourceObject.Lid != null)) - { - throw new JsonApiSerializationException("The 'data[].id' or 'data[].lid' element is required.", null, - atomicOperationIndex: index); - } - - var rightResourceContext = GetExistingResourceContext(resourceObject.Type, index); - if (!rightResourceContext.ResourceType.IsAssignableFrom(relationship.RightType)) - { - var relationshipRightTypeName = ResourceContextProvider.GetResourceContext(relationship.RightType); + var relationshipRightTypeName = ResourceContextProvider.GetResourceContext(relationship.RightType); - throw new JsonApiSerializationException("Resource type mismatch between 'ref.relationship' and 'data[].type' element.", - $@"Expected resource of type '{relationshipRightTypeName}' in 'data[].type', instead of '{rightResourceContext.PublicName}'.", - atomicOperationIndex: index); - } - } + throw new JsonApiSerializationException("Resource type mismatch between 'ref.relationship' and 'data[].type' element.", + $@"Expected resource of type '{relationshipRightTypeName}' in 'data[].type', instead of '{rightResourceContext.PublicName}'.", + atomicOperationIndex: AtomicOperationIndex); } + } + } - if (operation.SingleData != null) + if (operation.SingleData != null) + { + var resourceObject = operation.SingleData; + + if (resourceObject.Type == null) + { + throw new JsonApiSerializationException("The 'data.type' element is required.", null, + atomicOperationIndex: AtomicOperationIndex); + } + + if (kind != OperationKind.CreateResource) + { + if ((resourceObject.Id == null && resourceObject.Lid == null) || (resourceObject.Id != null && resourceObject.Lid != null)) + { + throw new JsonApiSerializationException("The 'data.id' or 'data.lid' element is required.", null, + atomicOperationIndex: AtomicOperationIndex); + } + } + + var dataResourceContext = GetExistingResourceContext(resourceObject.Type); + + if (kind.IsRelationship() && relationship != null) + { + var rightResourceContext = dataResourceContext; + if (!rightResourceContext.ResourceType.IsAssignableFrom(relationship.RightType)) { - var resourceObject = operation.SingleData; - - if (resourceObject.Type == null) - { - throw new JsonApiSerializationException("The 'data.type' element is required.", null, - atomicOperationIndex: index); - } - - if ((resourceObject.Id == null && resourceObject.Lid == null) || (resourceObject.Id != null && resourceObject.Lid != null)) - { - throw new JsonApiSerializationException("The 'data.id' or 'data.lid' element is required.", null, - atomicOperationIndex: index); - } - - var rightResourceContext = GetExistingResourceContext(resourceObject.Type, index); - if (!rightResourceContext.ResourceType.IsAssignableFrom(relationship.RightType)) - { - var relationshipRightTypeName = ResourceContextProvider.GetResourceContext(relationship.RightType); + var relationshipRightTypeName = ResourceContextProvider.GetResourceContext(relationship.RightType); - throw new JsonApiSerializationException("Resource type mismatch between 'ref.relationship' and 'data.type' element.", - $@"Expected resource of type '{relationshipRightTypeName}' in 'data.type', instead of '{rightResourceContext.PublicName}'.", - atomicOperationIndex: index); - } + throw new JsonApiSerializationException("Resource type mismatch between 'ref.relationship' and 'data.type' element.", + $@"Expected resource of type '{relationshipRightTypeName}' in 'data.type', instead of '{rightResourceContext.PublicName}'.", + atomicOperationIndex: AtomicOperationIndex); } } } - return ToOperationContainer(operation); + return ToOperationContainer(operation, kind); } - private OperationContainer ToOperationContainer(AtomicOperationObject operation) + private OperationContainer ToOperationContainer(AtomicOperationObject operation, OperationKind kind) { - var operationKind = GetOperationKind(operation); - var resourceName = operation.GetResourceTypeName(); - var primaryResourceContext = ResourceContextProvider.GetResourceContext(resourceName); + var primaryResourceContext = GetExistingResourceContext(resourceName); _targetedFields.Attributes.Clear(); _targetedFields.Relationships.Clear(); IIdentifiable resource; - switch (operationKind) + switch (kind) { case OperationKind.CreateResource: case OperationKind.UpdateResource: @@ -268,14 +272,14 @@ private OperationContainer ToOperationContainer(AtomicOperationObject operation) } default: { - throw new NotSupportedException($"Unknown operation kind '{operationKind}'."); + throw new NotSupportedException($"Unknown operation kind '{kind}'."); } } var request = new JsonApiRequest { Kind = EndpointKind.AtomicOperations, - OperationKind = operationKind, + OperationKind = kind, BasePath = "TODO: Set this...", PrimaryResource = primaryResourceContext }; @@ -286,11 +290,7 @@ private OperationContainer ToOperationContainer(AtomicOperationObject operation) if (operation.Ref.Relationship != null) { - var relationship = primaryResourceContext.Relationships.SingleOrDefault(r => r.PublicName == operation.Ref.Relationship); - if (relationship == null) - { - throw new InvalidOperationException("TODO: @OPS: Relationship does not exist."); - } + var relationship = primaryResourceContext.Relationships.Single(r => r.PublicName == operation.Ref.Relationship); var secondaryResourceContext = ResourceContextProvider.GetResourceContext(relationship.RightType); if (secondaryResourceContext == null) @@ -324,10 +324,10 @@ private OperationContainer ToOperationContainer(AtomicOperationObject operation) Relationships = _targetedFields.Relationships.ToHashSet() }; - return new OperationContainer(operationKind, resource, targetedFields, request); + return new OperationContainer(kind, resource, targetedFields, request); } - private static OperationKind GetOperationKind(AtomicOperationObject operation) + private OperationKind GetOperationKind(AtomicOperationObject operation) { switch (operation.Code) { @@ -341,6 +341,12 @@ private static OperationKind GetOperationKind(AtomicOperationObject operation) } 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; } default: @@ -354,7 +360,8 @@ private void AssertResourceIdIsNotTargeted() { if (!_request.IsReadOnly && _targetedFields.Attributes.Any(attribute => attribute.Property.Name == nameof(Identifiable.Id))) { - throw new JsonApiSerializationException("Resource ID is read-only.", null); + throw new JsonApiSerializationException("Resource ID is read-only.", null, + atomicOperationIndex: AtomicOperationIndex); } } @@ -374,7 +381,8 @@ protected override void AfterProcessField(IIdentifiable resource, ResourceFieldA { 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 && @@ -382,7 +390,8 @@ protected override void AfterProcessField(IIdentifiable resource, ResourceFieldA { 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); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index 94b3da2133..0e40cfb3fe 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -319,7 +319,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [Fact(Skip = "TODO: @OPS: Make this test work")] + [Fact] public async Task Cannot_create_resource_with_client_generated_ID() { // Arrange @@ -355,7 +355,7 @@ public async Task Cannot_create_resource_with_client_generated_ID() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); - responseDocument.Errors[0].Title.Should().Be("TODO: Specifying the resource ID in POST requests is not allowed."); + responseDocument.Errors[0].Title.Should().Be("Specifying the resource ID in POST requests is not allowed."); responseDocument.Errors[0].Detail.Should().BeNull(); responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); } @@ -425,7 +425,7 @@ public async Task Cannot_create_resource_for_ref_element() responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); } - [Fact(Skip = "TODO: @OPS: Make this test work")] + [Fact] public async Task Cannot_create_resource_for_missing_type() { // Arrange @@ -461,7 +461,7 @@ public async Task Cannot_create_resource_for_missing_type() responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); } - [Fact(Skip = "TODO: @OPS: Make this test work")] + [Fact] public async Task Cannot_create_resource_for_unknown_type() { // Arrange @@ -495,7 +495,7 @@ public async Task Cannot_create_resource_for_unknown_type() responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); } - [Fact(Skip = "TODO: @OPS: Make this test work")] + [Fact] public async Task Cannot_create_resource_attribute_with_blocked_capability() { // Arrange @@ -533,7 +533,7 @@ public async Task Cannot_create_resource_attribute_with_blocked_capability() responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); } - [Fact(Skip = "TODO: @OPS: Make this test work")] + [Fact] public async Task Cannot_create_resource_with_incompatible_attribute_value() { // Arrange @@ -567,8 +567,8 @@ public async Task Cannot_create_resource_with_incompatible_attribute_value() 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 'not-a-valid-time' of type 'String' to type 'DateTimeOffset'."); - responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[0]"); + 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] diff --git a/test/UnitTests/Serialization/DeserializerTestsSetup.cs b/test/UnitTests/Serialization/DeserializerTestsSetup.cs index b09ce1d184..2f8d4a053f 100644 --- a/test/UnitTests/Serialization/DeserializerTestsSetup.cs +++ b/test/UnitTests/Serialization/DeserializerTestsSetup.cs @@ -20,8 +20,6 @@ public DeserializerTestsSetup() } protected sealed class TestDeserializer : BaseDeserializer { - protected override bool AllowLocalIds => false; - public TestDeserializer(IResourceGraph resourceGraph, IResourceFactory resourceFactory) : base(resourceGraph, resourceFactory) { } public object Deserialize(string body) From de2acf2f5419214496e1a01f371a0994143cb0b9 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 7 Jan 2021 16:02:15 +0100 Subject: [PATCH 040/123] Added test for read-only attribute --- .../Creating/AtomicCreateResourceTests.cs | 43 ++++++++++++++++++- .../AtomicOperations/Playlist.cs | 4 ++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index 0e40cfb3fe..7fc59c550c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -532,7 +532,48 @@ public async Task Cannot_create_resource_attribute_with_blocked_capability() 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 = "/api/v1/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() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Playlist.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Playlist.cs index 8d88898b0c..11fd778cc2 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Playlist.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Playlist.cs @@ -12,6 +12,10 @@ public sealed class Playlist : Identifiable [Required] public string Name { get; set; } + [NotMapped] + [Attr] + public bool IsArchived => false; + [NotMapped] [HasManyThrough(nameof(PlaylistMusicTracks))] public IList Tracks { get; set; } From 626d58be7ba58f3b088b72f9cb1c5c4a92467bc2 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 7 Jan 2021 18:18:58 +0100 Subject: [PATCH 041/123] Added operation tests for Create resource with client-generated ID --- ...reateResourceWithClientGeneratedIdTests.cs | 185 ++++++++++++++++++ .../AtomicOperations/Lyric.cs | 3 + .../AtomicOperations/OperationsDbContext.cs | 1 + .../AtomicOperations/OperationsFakers.cs | 16 ++ .../AtomicOperations/TextLanguage.cs | 21 ++ 5 files changed, 226 insertions(+) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/TextLanguage.cs diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs new file mode 100644 index 0000000000..7e35b697af --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -0,0 +1,185 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Creating +{ + public sealed class AtomicCreateResourceWithClientGeneratedIdTests + : IClassFixture, OperationsDbContext>> + { + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicCreateResourceWithClientGeneratedIdTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + }); + + 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 = "/api/v1/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.Relationships.Should().BeNull(); + + 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 = "/api/v1/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(track => track.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 = "/api/v1/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]"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Lyric.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Lyric.cs index 29e0afe04b..a02949e2e9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Lyric.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Lyric.cs @@ -12,6 +12,9 @@ public sealed class Lyric : Identifiable [Attr] public string Text { get; set; } + [HasOne] + public TextLanguage Language { get; set; } + [Attr(Capabilities = AttrCapabilities.None)] public DateTimeOffset CreatedAt { get; set; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs index 0bd93e403b..0e8ab53cdf 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs @@ -8,6 +8,7 @@ public sealed class OperationsDbContext : DbContext 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; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsFakers.cs index ec44a47fb8..e77bdaa1a6 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsFakers.cs @@ -1,10 +1,20 @@ using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; using Bogus; 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()) @@ -24,6 +34,11 @@ internal sealed class OperationsFakers : FakerContainer .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()) @@ -39,6 +54,7 @@ internal sealed class OperationsFakers : FakerContainer 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/TextLanguage.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/TextLanguage.cs new file mode 100644 index 0000000000..d90e83bd0d --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/TextLanguage.cs @@ -0,0 +1,21 @@ +using System; +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 { } + } + } +} From f4a3447bb51db92c1a47f8fc6a048ffd234e8dc8 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 7 Jan 2021 18:38:25 +0100 Subject: [PATCH 042/123] Reordered tests --- .../Deleting/AtomicDeleteResourceTests.cs | 54 +++--- .../AtomicUpdateToOneRelationshipTests.cs | 174 +++++++++--------- 2 files changed, 114 insertions(+), 114 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs index 591ac11cf9..26a4186b74 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs @@ -129,17 +129,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => tracksInDatabase.Should().BeEmpty(); }); } - + [Fact] - public async Task Can_delete_resource_with_OneToOne_relationship_from_dependent_side() + public async Task Can_delete_resource_with_OneToOne_relationship_from_principal_side() { // Arrange - var existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Lyric = _fakers.Lyric.Generate(); - + var existingLyric = _fakers.Lyric.Generate(); + existingLyric.Track = _fakers.MusicTrack.Generate(); + await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.MusicTracks.Add(existingTrack); + dbContext.Lyrics.Add(existingLyric); await dbContext.SaveChangesAsync(); }); @@ -152,8 +152,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => op = "remove", @ref = new { - type = "musicTracks", - id = existingTrack.StringId + type = "lyrics", + id = existingLyric.StringId } } } @@ -171,28 +171,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - var tracksInDatabase = await dbContext.MusicTracks - .FirstOrDefaultAsync(musicTrack => musicTrack.Id == existingTrack.Id); + var lyricsInDatabase = await dbContext.Lyrics + .FirstOrDefaultAsync(lyric => lyric.Id == existingLyric.Id); - tracksInDatabase.Should().BeNull(); + lyricsInDatabase.Should().BeNull(); - var lyricInDatabase = await dbContext.Lyrics - .FirstAsync(lyric => lyric.Id == existingTrack.Lyric.Id); + var trackInDatabase = await dbContext.MusicTracks + .FirstAsync(musicTrack => musicTrack.Id == existingLyric.Track.Id); - lyricInDatabase.Track.Should().BeNull(); + trackInDatabase.Lyric.Should().BeNull(); }); } [Fact] - public async Task Can_delete_resource_with_OneToOne_relationship_from_principal_side() + public async Task Can_delete_resource_with_OneToOne_relationship_from_dependent_side() { // Arrange - var existingLyric = _fakers.Lyric.Generate(); - existingLyric.Track = _fakers.MusicTrack.Generate(); - + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Lyric = _fakers.Lyric.Generate(); + await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Lyrics.Add(existingLyric); + dbContext.MusicTracks.Add(existingTrack); await dbContext.SaveChangesAsync(); }); @@ -205,8 +205,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => op = "remove", @ref = new { - type = "lyrics", - id = existingLyric.StringId + type = "musicTracks", + id = existingTrack.StringId } } } @@ -224,15 +224,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - var lyricsInDatabase = await dbContext.Lyrics - .FirstOrDefaultAsync(lyric => lyric.Id == existingLyric.Id); + var tracksInDatabase = await dbContext.MusicTracks + .FirstOrDefaultAsync(musicTrack => musicTrack.Id == existingTrack.Id); - lyricsInDatabase.Should().BeNull(); + tracksInDatabase.Should().BeNull(); - var trackInDatabase = await dbContext.MusicTracks - .FirstAsync(musicTrack => musicTrack.Id == existingLyric.Track.Id); + var lyricInDatabase = await dbContext.Lyrics + .FirstAsync(lyric => lyric.Id == existingTrack.Lyric.Id); - trackInDatabase.Lyric.Should().BeNull(); + lyricInDatabase.Track.Should().BeNull(); }); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs index a5cc78d633..347417116d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs @@ -28,18 +28,18 @@ public AtomicUpdateToOneRelationshipTests( services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); }); } - + [Fact] - public async Task Can_clear_OneToOne_relationship_from_dependent_side() + public async Task Can_clear_OneToOne_relationship_from_principal_side() { // Arrange - var existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Lyric = _fakers.Lyric.Generate(); + var existingLyric = _fakers.Lyric.Generate(); + existingLyric.Track = _fakers.MusicTrack.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); + await dbContext.ClearTableAsync(); + dbContext.Lyrics.Add(existingLyric); await dbContext.SaveChangesAsync(); }); @@ -52,9 +52,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => op = "update", @ref = new { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "lyric" + type = "lyrics", + id = existingLyric.StringId, + relationship = "track" }, data = (object)null } @@ -73,28 +73,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - var trackInDatabase = await dbContext.MusicTracks - .Include(musicTrack => musicTrack.Lyric) - .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + var lyricInDatabase = await dbContext.Lyrics + .Include(lyric => lyric.Track) + .FirstAsync(lyric => lyric.Id == existingLyric.Id); - trackInDatabase.Lyric.Should().BeNull(); + lyricInDatabase.Track.Should().BeNull(); - var lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.Should().HaveCount(1); + var tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.Should().HaveCount(1); }); } [Fact] - public async Task Can_clear_OneToOne_relationship_from_principal_side() + public async Task Can_clear_OneToOne_relationship_from_dependent_side() { // Arrange - var existingLyric = _fakers.Lyric.Generate(); - existingLyric.Track = _fakers.MusicTrack.Generate(); + var existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Lyric = _fakers.Lyric.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); - dbContext.Lyrics.Add(existingLyric); + await dbContext.ClearTableAsync(); + dbContext.MusicTracks.Add(existingTrack); await dbContext.SaveChangesAsync(); }); @@ -107,9 +107,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => op = "update", @ref = new { - type = "lyrics", - id = existingLyric.StringId, - relationship = "track" + type = "musicTracks", + id = existingTrack.StringId, + relationship = "lyric" }, data = (object)null } @@ -128,14 +128,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - var lyricInDatabase = await dbContext.Lyrics - .Include(lyric => lyric.Track) - .FirstAsync(lyric => lyric.Id == existingLyric.Id); + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Lyric) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); - lyricInDatabase.Track.Should().BeNull(); + trackInDatabase.Lyric.Should().BeNull(); - var tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(1); + var lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); + lyricsInDatabase.Should().HaveCount(1); }); } @@ -195,15 +195,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_create_OneToOne_relationship_from_dependent_side() + public async Task Can_create_OneToOne_relationship_from_principal_side() { // Arrange - var existingTrack = _fakers.MusicTrack.Generate(); var existingLyric = _fakers.Lyric.Generate(); + var existingTrack = _fakers.MusicTrack.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddRange(existingTrack, existingLyric); + dbContext.AddRange(existingLyric, existingTrack); await dbContext.SaveChangesAsync(); }); @@ -216,14 +216,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => op = "update", @ref = new { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "lyric" + type = "lyrics", + id = existingLyric.StringId, + relationship = "track" }, data = new { - type = "lyrics", - id = existingLyric.StringId + type = "musicTracks", + id = existingTrack.StringId } } } @@ -241,24 +241,24 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - var trackInDatabase = await dbContext.MusicTracks - .Include(musicTrack => musicTrack.Lyric) - .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + var lyricInDatabase = await dbContext.Lyrics + .Include(lyric => lyric.Track) + .FirstAsync(lyric => lyric.Id == existingLyric.Id); - trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); + lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); }); } [Fact] - public async Task Can_create_OneToOne_relationship_from_principal_side() + public async Task Can_create_OneToOne_relationship_from_dependent_side() { // Arrange - var existingLyric = _fakers.Lyric.Generate(); var existingTrack = _fakers.MusicTrack.Generate(); + var existingLyric = _fakers.Lyric.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.AddRange(existingLyric, existingTrack); + dbContext.AddRange(existingTrack, existingLyric); await dbContext.SaveChangesAsync(); }); @@ -271,14 +271,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => op = "update", @ref = new { - type = "lyrics", - id = existingLyric.StringId, - relationship = "track" + type = "musicTracks", + id = existingTrack.StringId, + relationship = "lyric" }, data = new { - type = "musicTracks", - id = existingTrack.StringId + type = "lyrics", + id = existingLyric.StringId } } } @@ -296,11 +296,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - var lyricInDatabase = await dbContext.Lyrics - .Include(lyric => lyric.Track) - .FirstAsync(lyric => lyric.Id == existingLyric.Id); + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Lyric) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); - lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); + trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); }); } @@ -360,18 +360,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_replace_OneToOne_relationship_from_dependent_side() + public async Task Can_replace_OneToOne_relationship_from_principal_side() { // Arrange - var existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Lyric = _fakers.Lyric.Generate(); - 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(existingTrack, existingLyric); + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingLyric, existingTrack); await dbContext.SaveChangesAsync(); }); @@ -384,14 +384,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => op = "update", @ref = new { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "lyric" + type = "lyrics", + id = existingLyric.StringId, + relationship = "track" }, data = new { - type = "lyrics", - id = existingLyric.StringId + type = "musicTracks", + id = existingTrack.StringId } } } @@ -409,30 +409,30 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - var trackInDatabase = await dbContext.MusicTracks - .Include(musicTrack => musicTrack.Lyric) - .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); + var lyricInDatabase = await dbContext.Lyrics + .Include(lyric => lyric.Track) + .FirstAsync(lyric => lyric.Id == existingLyric.Id); - trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); + lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); - var lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.Should().HaveCount(2); + var tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); + tracksInDatabase.Should().HaveCount(2); }); } [Fact] - public async Task Can_replace_OneToOne_relationship_from_principal_side() + public async Task Can_replace_OneToOne_relationship_from_dependent_side() { // Arrange - var existingLyric = _fakers.Lyric.Generate(); - existingLyric.Track = _fakers.MusicTrack.Generate(); - 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(existingLyric, existingTrack); + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingTrack, existingLyric); await dbContext.SaveChangesAsync(); }); @@ -445,14 +445,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => op = "update", @ref = new { - type = "lyrics", - id = existingLyric.StringId, - relationship = "track" + type = "musicTracks", + id = existingTrack.StringId, + relationship = "lyric" }, data = new { - type = "musicTracks", - id = existingTrack.StringId + type = "lyrics", + id = existingLyric.StringId } } } @@ -470,14 +470,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - var lyricInDatabase = await dbContext.Lyrics - .Include(lyric => lyric.Track) - .FirstAsync(lyric => lyric.Id == existingLyric.Id); + var trackInDatabase = await dbContext.MusicTracks + .Include(musicTrack => musicTrack.Lyric) + .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); - lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); + trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - var tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(2); + var lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); + lyricsInDatabase.Should().HaveCount(2); }); } From 0b824e33f8f876cdaca7c949d146d07033257709 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 7 Jan 2021 20:54:11 +0100 Subject: [PATCH 043/123] Added operation tests for Create resource with ToOne relationship --- .../Serialization/BaseDeserializer.cs | 2 +- ...reateResourceWithClientGeneratedIdTests.cs | 6 +- ...reateResourceWithToOneRelationshipTests.cs | 443 +++++++++++++++++- 3 files changed, 434 insertions(+), 17 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index 7f04c77942..c50d412c9d 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -280,7 +280,7 @@ private void AssertHasIdOrLid(ResourceIdentifierObject resourceIdentifierObject, if (hasNone || hasBoth) { - throw new JsonApiSerializationException("TODO: Request body must include 'id' or 'lid' element.", + throw new JsonApiSerializationException("Request body must include 'id' or 'lid' element.", $"Expected 'id' or 'lid' element in '{relationship.PublicName}' relationship.", atomicOperationIndex: AtomicOperationIndex); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs index 7e35b697af..da9e04ccc6 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -58,7 +58,7 @@ public async Task Can_create_resource_with_client_generated_guid_ID_having_side_ } } }; - + var route = "/api/v1/operations"; // Act @@ -109,7 +109,7 @@ public async Task Can_create_resource_with_client_generated_string_ID_having_no_ } } }; - + var route = "/api/v1/operations"; // Act @@ -123,7 +123,7 @@ public async Task Can_create_resource_with_client_generated_string_ID_having_no_ await _testContext.RunOnDatabaseAsync(async dbContext => { var trackInDatabase = await dbContext.MusicTracks - .FirstAsync(track => track.Id == newTrack.Id); + .FirstAsync(musicTrack => musicTrack.Id == newTrack.Id); trackInDatabase.Title.Should().Be(newTrack.Title); trackInDatabase.LengthInSeconds.Should().BeApproximately(newTrack.LengthInSeconds, 0.00000000001M); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs index 4be01fa8cd..4c08b96114 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Creating @@ -31,16 +32,81 @@ public AtomicCreateResourceWithToOneRelationshipTests(IntegrationTestContext + { + 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 = "/api/v1/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.RecordCompanies.Add(existingCompany); + dbContext.Lyrics.Add(existingLyric); await dbContext.SaveChangesAsync(); }); @@ -60,12 +126,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }, relationships = new { - ownedBy = new + lyric = new { data = new { - type = "recordCompanies", - id = existingCompany.StringId + type = "lyrics", + id = existingLyric.StringId } } } @@ -73,7 +139,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } } }; - + var route = "/api/v1/operations"; // Act @@ -85,20 +151,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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(newTrackTitle); + 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) + .Include(musicTrack => musicTrack.Lyric) .FirstAsync(musicTrack => musicTrack.Id == newTrackId); - trackInDatabase.Title.Should().Be(newTrackTitle); - - trackInDatabase.OwnedBy.Should().NotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); + trackInDatabase.Lyric.Should().NotBeNull(); + trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); }); } @@ -191,5 +256,357 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }); } + + [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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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]"); + } } } From 6e1b9d5eaf92cb5208f4cdd1a2c70073108104e9 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 7 Jan 2021 21:36:54 +0100 Subject: [PATCH 044/123] Added operation tests for Create resource with ToMany relationship --- ...eateResourceWithToManyRelationshipTests.cs | 616 ++++++++++++++++++ ...eateResourceWithToManyRelationshipTests.cs | 4 +- ...reateResourceWithToOneRelationshipTests.cs | 2 +- 3 files changed, 619 insertions(+), 3 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs new file mode 100644 index 0000000000..86a9d36ac4 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs @@ -0,0 +1,616 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Creating +{ + public sealed class AtomicCreateResourceWithToManyRelationshipTests + : IClassFixture, OperationsDbContext>> + { + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicCreateResourceWithToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + }); + } + + [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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs index 44670db99b..e697537a42 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs @@ -484,11 +484,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] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs index f6ccdf172e..bfd92a428b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs @@ -427,7 +427,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] From a090abb1bea6bad99437cc949aebba1dfdd3875f Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 7 Jan 2021 22:36:32 +0100 Subject: [PATCH 045/123] Added tests for unknown ID in ref --- ...eateResourceWithToManyRelationshipTests.cs | 2 - .../AtomicAddToToManyRelationshipTests.cs | 52 +++++++++++++++++++ ...AtomicRemoveFromToManyRelationshipTests.cs | 52 +++++++++++++++++++ .../AtomicReplaceToManyRelationshipTests.cs | 52 +++++++++++++++++++ .../AtomicUpdateToOneRelationshipTests.cs | 51 ++++++++++++++++++ 5 files changed, 207 insertions(+), 2 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs index 86a9d36ac4..ce1b54beee 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs @@ -444,7 +444,6 @@ public async Task Cannot_create_on_relationship_type_mismatch() 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'."); @@ -565,7 +564,6 @@ public async Task Cannot_create_with_null_data_in_HasMany_relationship() 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."); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs index b63a904195..c782035a25 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs @@ -387,6 +387,58 @@ public async Task Cannot_add_for_missing_ID_in_ref() 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 = "/api/v1/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() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs index 713155f30e..0c50563601 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs @@ -393,6 +393,58 @@ public async Task Cannot_remove_for_missing_ID_in_ref() 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 = "/api/v1/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() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs index 2b51a27736..230592ffd2 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs @@ -439,6 +439,58 @@ public async Task Cannot_replace_for_missing_ID_in_ref() 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 = "/api/v1/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_ID_and_local_ID_in_ref() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs index 347417116d..971f927c72 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs @@ -679,6 +679,57 @@ public async Task Cannot_create_for_missing_ID_in_ref() 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 = "/api/v1/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_ID_and_local_ID_in_ref() { From fc63438306e0fdf1ad9582691f31299feaeb44a7 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 7 Jan 2021 22:36:40 +0100 Subject: [PATCH 046/123] Reordered tests --- .../Deleting/AtomicDeleteResourceTests.cs | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs index 26a4186b74..5506a7ee67 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs @@ -510,7 +510,7 @@ public async Task Cannot_delete_resource_for_missing_ID() } [Fact] - public async Task Cannot_delete_resource_for_ID_and_local_ID() + public async Task Cannot_delete_resource_for_unknown_ID() { // Arrange var requestBody = new @@ -522,9 +522,8 @@ public async Task Cannot_delete_resource_for_ID_and_local_ID() op = "remove", @ref = new { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - lid = "local-1" + type = "performers", + id = 99999999 } } } @@ -536,19 +535,21 @@ public async Task Cannot_delete_resource_for_ID_and_local_ID() var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); 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].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_unknown_ID() + public async Task Cannot_delete_resource_for_incompatible_ID() { // Arrange + var guid = Guid.NewGuid().ToString(); + var requestBody = new { atomic__operations = new[] @@ -558,8 +559,8 @@ public async Task Cannot_delete_resource_for_unknown_ID() op = "remove", @ref = new { - type = "performers", - id = 99999999 + type = "playlists", + id = guid } } } @@ -569,23 +570,21 @@ public async Task Cannot_delete_resource_for_unknown_ID() // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - + // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); 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].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_incompatible_ID() + public async Task Cannot_delete_resource_for_ID_and_local_ID() { // Arrange - var guid = Guid.NewGuid().ToString(); - var requestBody = new { atomic__operations = new[] @@ -595,8 +594,9 @@ public async Task Cannot_delete_resource_for_incompatible_ID() op = "remove", @ref = new { - type = "playlists", - id = guid + type = "musicTracks", + id = Guid.NewGuid().ToString(), + lid = "local-1" } } } @@ -606,14 +606,14 @@ public async Task Cannot_delete_resource_for_incompatible_ID() // 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].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]"); } } From 5167fdc6da2cb96bc808cddd8ca39afdbe17eaf9 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 12 Jan 2021 11:26:49 +0100 Subject: [PATCH 047/123] Fixed: exclude attributes without AttrCapabilities.AllowView flag in response. --- .../AtomicOperationsResponseSerializer.cs | 12 +++++++++++- ...AtomicCreateResourceWithClientGeneratedIdTests.cs | 1 + 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs index 1bee7b5f58..88e58bd69d 100644 --- a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; +using System.Linq; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Building; using JsonApiDotNetCore.Serialization.Objects; @@ -55,7 +57,15 @@ private string SerializeOperationsDocument(IEnumerable resources) if (resource != null) { var resourceContext = _resourceContextProvider.GetResourceContext(resource.GetType()); - resourceObject = ResourceObjectBuilder.Build(resource, resourceContext.Attributes, resourceContext.Relationships); + + // TODO: @OPS: Should inject IFieldsToSerialize, which uses SparseFieldSetCache to call into resource definitions to hide fields. + // But then we need to update IJsonApiRequest for each loop entry, which we don't have access to anymore. + + var attributes = resourceContext.Attributes + .Where(attr => attr.Capabilities.HasFlag(AttrCapabilities.AllowView)) + .ToArray(); + + resourceObject = ResourceObjectBuilder.Build(resource, attributes, resourceContext.Relationships); } document.Results.Add(new AtomicResultObject diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs index da9e04ccc6..38b61a221f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -71,6 +71,7 @@ public async Task Can_create_resource_with_client_generated_guid_ID_having_side_ 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().BeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => From 10b8c1b46a321dc0e4ab28a0bda02e1786d27d9b Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 13 Jan 2021 21:16:10 +0100 Subject: [PATCH 048/123] Added operation tests for Update resource --- .../Serialization/RequestDeserializer.cs | 170 ++- .../Creating/AtomicCreateResourceTests.cs | 67 +- ...reateResourceWithClientGeneratedIdTests.cs | 2 +- ...eateResourceWithToManyRelationshipTests.cs | 3 - ...reateResourceWithToOneRelationshipTests.cs | 3 - .../AtomicModelStateValidationTests.cs | 1 - .../AtomicOperations/TextLanguage.cs | 4 + .../Resources/AtomicUpdateResourceTests.cs | 1355 ++++++++++++++++- 8 files changed, 1474 insertions(+), 131 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index 37671c40c3..9d41ea2898 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -54,8 +54,8 @@ public object DeserializeDocument(string body) var instance = DeserializeBody(body); - AssertResourceIdIsNotTargeted(); - + AssertResourceIdIsNotTargeted(_targetedFields); + return instance; } @@ -104,7 +104,8 @@ private OperationContainer DeserializeOperation(AtomicOperationObject operation) atomicOperationIndex: AtomicOperationIndex); } - RelationshipAttribute relationship = null; + RelationshipAttribute relationshipInRef = null; + ResourceContext resourceContextInRef = null; if (operation.Ref != null) { @@ -114,7 +115,7 @@ private OperationContainer DeserializeOperation(AtomicOperationObject operation) atomicOperationIndex: AtomicOperationIndex); } - var resourceContext = GetExistingResourceContext(operation.Ref.Type); + resourceContextInRef = GetExistingResourceContext(operation.Ref.Type); if ((operation.Ref.Id == null && operation.Ref.Lid == null) || (operation.Ref.Id != null && operation.Ref.Lid != null)) { @@ -126,7 +127,7 @@ private OperationContainer DeserializeOperation(AtomicOperationObject operation) { try { - TypeHelper.ConvertType(operation.Ref.Id, resourceContext.IdentityType); + TypeHelper.ConvertType(operation.Ref.Id, resourceContextInRef.IdentityType); } catch (FormatException exception) { @@ -136,8 +137,8 @@ private OperationContainer DeserializeOperation(AtomicOperationObject operation) if (operation.Ref.Relationship != null) { - relationship = resourceContext.Relationships.FirstOrDefault(r => r.PublicName == operation.Ref.Relationship); - if (relationship == null) + relationshipInRef = resourceContextInRef.Relationships.FirstOrDefault(r => r.PublicName == operation.Ref.Relationship); + if (relationshipInRef == null) { throw new JsonApiSerializationException( "The referenced relationship does not exist.", @@ -145,7 +146,7 @@ private OperationContainer DeserializeOperation(AtomicOperationObject operation) atomicOperationIndex: AtomicOperationIndex); } - if ((kind == OperationKind.AddToRelationship || kind == OperationKind.RemoveFromRelationship) && relationship is HasOneAttribute) + if ((kind == OperationKind.AddToRelationship || kind == OperationKind.RemoveFromRelationship) && relationshipInRef is HasOneAttribute) { throw new JsonApiSerializationException( $"Only to-many relationships can be targeted in '{operation.Code.ToString().Camelize()}' operations.", @@ -153,19 +154,19 @@ private OperationContainer DeserializeOperation(AtomicOperationObject operation) atomicOperationIndex: AtomicOperationIndex); } - if (relationship is HasOneAttribute && operation.ManyData != null) + if (relationshipInRef is HasOneAttribute && operation.ManyData != null) { throw new JsonApiSerializationException( "Expected single data element for to-one relationship.", - $"Expected single data element for '{relationship.PublicName}' relationship.", + $"Expected single data element for '{relationshipInRef.PublicName}' relationship.", atomicOperationIndex: AtomicOperationIndex); } - if (relationship is HasManyAttribute && operation.ManyData == null) + if (relationshipInRef is HasManyAttribute && operation.ManyData == null) { throw new JsonApiSerializationException( "Expected data[] element for to-many relationship.", - $"Expected data[] element for '{relationship.PublicName}' relationship.", + $"Expected data[] element for '{relationshipInRef.PublicName}' relationship.", atomicOperationIndex: AtomicOperationIndex); } } @@ -175,6 +176,12 @@ private OperationContainer DeserializeOperation(AtomicOperationObject operation) { foreach (var resourceObject in operation.ManyData) { + if (relationshipInRef == null) + { + throw new JsonApiSerializationException("Expected single data element for create/update resource operation.", + null, atomicOperationIndex: AtomicOperationIndex); + } + if (resourceObject.Type == null) { throw new JsonApiSerializationException("The 'data[].type' element is required.", null, @@ -187,15 +194,10 @@ private OperationContainer DeserializeOperation(AtomicOperationObject operation) atomicOperationIndex: AtomicOperationIndex); } - if (relationship == null) - { - throw new Exception("TODO: @OPS: This happens when sending an array for CreateResource/UpdateResource."); - } - var rightResourceContext = GetExistingResourceContext(resourceObject.Type); - if (!rightResourceContext.ResourceType.IsAssignableFrom(relationship.RightType)) + if (!rightResourceContext.ResourceType.IsAssignableFrom(relationshipInRef.RightType)) { - var relationshipRightTypeName = ResourceContextProvider.GetResourceContext(relationship.RightType); + var relationshipRightTypeName = ResourceContextProvider.GetResourceContext(relationshipInRef.RightType); throw new JsonApiSerializationException("Resource type mismatch between 'ref.relationship' and 'data[].type' element.", $@"Expected resource of type '{relationshipRightTypeName}' in 'data[].type', instead of '{rightResourceContext.PublicName}'.", @@ -223,20 +225,60 @@ private OperationContainer DeserializeOperation(AtomicOperationObject operation) } } - var dataResourceContext = GetExistingResourceContext(resourceObject.Type); + var resourceContextInData = GetExistingResourceContext(resourceObject.Type); - if (kind.IsRelationship() && relationship != null) + if (kind.IsRelationship() && relationshipInRef != null) { - var rightResourceContext = dataResourceContext; - if (!rightResourceContext.ResourceType.IsAssignableFrom(relationship.RightType)) + var rightResourceContext = resourceContextInData; + if (!rightResourceContext.ResourceType.IsAssignableFrom(relationshipInRef.RightType)) { - var relationshipRightTypeName = ResourceContextProvider.GetResourceContext(relationship.RightType); + var relationshipRightTypeName = ResourceContextProvider.GetResourceContext(relationshipInRef.RightType); throw new JsonApiSerializationException("Resource type mismatch between 'ref.relationship' and 'data.type' element.", $@"Expected resource of type '{relationshipRightTypeName}' in 'data.type', instead of '{rightResourceContext.PublicName}'.", atomicOperationIndex: AtomicOperationIndex); } } + else + { + if (resourceContextInRef != null && resourceContextInRef != resourceContextInData) + { + throw new JsonApiSerializationException("Resource type mismatch between 'ref.type' and 'data.type' element.", + $@"Expected resource of type '{resourceContextInRef.PublicName}' in 'data.type', instead of '{resourceContextInData.PublicName}'.", + atomicOperationIndex: AtomicOperationIndex); + } + + if (operation.Ref != null) + { + if (operation.Ref.Id != null && resourceObject.Id != null && resourceObject.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 '{resourceObject.Id}'.", + atomicOperationIndex: AtomicOperationIndex); + } + + if (operation.Ref.Lid != null && resourceObject.Lid != null && resourceObject.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 '{resourceObject.Lid}'.", + atomicOperationIndex: AtomicOperationIndex); + } + + if (operation.Ref.Id != null && resourceObject.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 '{resourceObject.Lid}' in 'data.lid'.", + atomicOperationIndex: AtomicOperationIndex); + } + + if (operation.Ref.Lid != null && resourceObject.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 '{resourceObject.Id}' in 'data.id'.", + atomicOperationIndex: AtomicOperationIndex); + } + } + } } return ToOperationContainer(operation, kind); @@ -257,6 +299,9 @@ private OperationContainer ToOperationContainer(AtomicOperationObject operation, case OperationKind.CreateResource: case OperationKind.UpdateResource: { + // TODO: @OPS: Chicken-and-egg problem: ParseResourceObject depends on _request.OperationKind, which is not built yet. + ((JsonApiRequest) _request).OperationKind = kind; + resource = ParseResourceObject(operation.SingleData); break; } @@ -280,41 +325,37 @@ private OperationContainer ToOperationContainer(AtomicOperationObject operation, { Kind = EndpointKind.AtomicOperations, OperationKind = kind, + PrimaryId = resource.StringId, BasePath = "TODO: Set this...", PrimaryResource = primaryResourceContext }; - if (operation.Ref != null) + if (operation.Ref?.Relationship != null) { - request.PrimaryId = operation.Ref.Id; + var relationship = primaryResourceContext.Relationships.Single(r => r.PublicName == operation.Ref.Relationship); - if (operation.Ref.Relationship != null) + var secondaryResourceContext = ResourceContextProvider.GetResourceContext(relationship.RightType); + if (secondaryResourceContext == null) { - var relationship = primaryResourceContext.Relationships.Single(r => r.PublicName == operation.Ref.Relationship); - - var secondaryResourceContext = ResourceContextProvider.GetResourceContext(relationship.RightType); - if (secondaryResourceContext == null) - { - throw new InvalidOperationException("TODO: @OPS: Secondary resource type does not exist."); - } + throw new InvalidOperationException("TODO: @OPS: Secondary resource type does not exist."); + } - request.SecondaryResource = secondaryResourceContext; - request.Relationship = relationship; - request.IsCollection = relationship is HasManyAttribute; + request.SecondaryResource = secondaryResourceContext; + request.Relationship = relationship; + request.IsCollection = relationship is HasManyAttribute; - _targetedFields.Relationships.Add(relationship); + _targetedFields.Relationships.Add(relationship); - if (operation.SingleData != null) - { - var rightResource = ParseResourceObject(operation.SingleData); - relationship.SetValue(resource, rightResource); - } - else if (operation.ManyData != null) - { - var secondaryResources = operation.ManyData.Select(ParseResourceObject).ToArray(); - var rightResources = TypeHelper.CopyToTypedCollection(secondaryResources, relationship.Property.PropertyType); - relationship.SetValue(resource, rightResources); - } + if (operation.SingleData != null) + { + var rightResource = ParseResourceObject(operation.SingleData); + relationship.SetValue(resource, rightResource); + } + else if (operation.ManyData != null) + { + var secondaryResources = operation.ManyData.Select(ParseResourceObject).ToArray(); + var rightResources = TypeHelper.CopyToTypedCollection(secondaryResources, relationship.Property.PropertyType); + relationship.SetValue(resource, rightResources); } } @@ -324,6 +365,8 @@ private OperationContainer ToOperationContainer(AtomicOperationObject operation, Relationships = _targetedFields.Relationships.ToHashSet() }; + AssertResourceIdIsNotTargeted(targetedFields); + return new OperationContainer(kind, resource, targetedFields, request); } @@ -356,9 +399,9 @@ private OperationKind GetOperationKind(AtomicOperationObject operation) } } - private void AssertResourceIdIsNotTargeted() + private void AssertResourceIdIsNotTargeted(ITargetedFields targetedFields) { - if (!_request.IsReadOnly && _targetedFields.Attributes.Any(attribute => attribute.Property.Name == nameof(Identifiable.Id))) + if (!_request.IsReadOnly && targetedFields.Attributes.Any(attribute => attribute.Property.Name == nameof(Identifiable.Id))) { throw new JsonApiSerializationException("Resource ID is read-only.", null, atomicOperationIndex: AtomicOperationIndex); @@ -374,10 +417,12 @@ private void AssertResourceIdIsNotTargeted() /// Relationship data for . Is null when is not a . 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.", @@ -385,8 +430,7 @@ protected override void AfterProcessField(IIdentifiable resource, ResourceFieldA 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.", @@ -399,5 +443,21 @@ protected override void AfterProcessField(IIdentifiable resource, ResourceFieldA 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/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index 7fc59c550c..8ac6f4fdd1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -57,7 +57,7 @@ public async Task Can_create_resource() } } }; - + var route = "/api/v1/operations"; // Act @@ -117,7 +117,7 @@ public async Task Can_create_resources() { atomic__operations = operationElements }; - + var route = "/api/v1/operations"; // Act @@ -185,7 +185,7 @@ public async Task Can_create_resource_without_attributes_or_relationships() } } }; - + var route = "/api/v1/operations"; // Act @@ -238,7 +238,7 @@ public async Task Can_create_resource_with_unknown_attribute() } } }; - + var route = "/api/v1/operations"; // Act @@ -293,7 +293,7 @@ public async Task Can_create_resource_with_unknown_relationship() } } }; - + var route = "/api/v1/operations"; // Act @@ -324,7 +324,7 @@ public async Task Cannot_create_resource_with_client_generated_ID() { // Arrange var newTitle = _fakers.MusicTrack.Generate().Title; - + var requestBody = new { atomic__operations = new[] @@ -344,7 +344,7 @@ public async Task Cannot_create_resource_with_client_generated_ID() } } }; - + var route = "/api/v1/operations"; // Act @@ -450,7 +450,7 @@ public async Task Cannot_create_resource_for_missing_type() // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - + // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); @@ -494,7 +494,50 @@ public async Task Cannot_create_resource_for_unknown_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 = "/api/v1/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() { @@ -517,7 +560,7 @@ public async Task Cannot_create_resource_attribute_with_blocked_capability() } } }; - + var route = "/api/v1/operations"; // Act @@ -596,7 +639,7 @@ public async Task Cannot_create_resource_with_incompatible_attribute_value() } } }; - + var route = "/api/v1/operations"; // Act @@ -676,7 +719,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } } }; - + var route = "/api/v1/operations"; // Act diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs index 38b61a221f..d88310d5ab 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -72,7 +72,7 @@ public async Task Can_create_resource_with_client_generated_guid_ID_having_side_ 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().BeNull(); + responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs index ce1b54beee..088cda8c8e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs @@ -33,7 +33,6 @@ 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 => @@ -113,7 +112,6 @@ 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 => @@ -455,7 +453,6 @@ public async Task Can_create_with_duplicates() { // Arrange var existingPerformer = _fakers.Performer.Generate(); - var newTitle = _fakers.MusicTrack.Generate().Title; await _testContext.RunOnDatabaseAsync(async dbContext => diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs index 4c08b96114..7901e42b76 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs @@ -101,7 +101,6 @@ 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 => @@ -174,7 +173,6 @@ public async Task Can_create_resources_with_ToOne_relationship() 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 => @@ -485,7 +483,6 @@ 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 => diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs index 700df9b745..ad52dff9f1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs @@ -76,7 +76,6 @@ 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 => diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/TextLanguage.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/TextLanguage.cs index d90e83bd0d..57452eb2d2 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/TextLanguage.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/TextLanguage.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -17,5 +18,8 @@ public Guid ConcurrencyToken get => Guid.NewGuid(); set { } } + + [HasMany] + public ICollection Lyrics { get; set; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs index 617a8da9a7..dc3d40b31c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs @@ -3,6 +3,7 @@ using System.Net; using System.Threading.Tasks; using FluentAssertions; +using FluentAssertions.Extensions; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; using Microsoft.AspNetCore.Mvc.ApplicationParts; @@ -30,14 +31,78 @@ public AtomicUpdateResourceTests(IntegrationTestContext 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 = "/api/v1/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(); - var newTitle = _fakers.MusicTrack.Generate().Title; - await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.MusicTracks.Add(existingTrack); @@ -57,7 +122,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingTrack.StringId, attributes = new { - title = newTitle + }, + relationships = new + { } } } @@ -76,57 +143,110 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - var tracksInDatabase = await dbContext.MusicTracks + var trackInDatabase = await dbContext.MusicTracks .Include(musicTrack => musicTrack.OwnedBy) .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); - tracksInDatabase.Title.Should().Be(newTitle); - tracksInDatabase.Genre.Should().Be(existingTrack.Genre); + trackInDatabase.Title.Should().Be(existingTrack.Title); + trackInDatabase.Genre.Should().Be(existingTrack.Genre); - tracksInDatabase.OwnedBy.Should().NotBeNull(); - tracksInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); + trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); }); } [Fact] - public async Task Can_update_resources() + public async Task Can_update_resource_with_unknown_attribute() { // Arrange - const int elementCount = 5; - - var existingTracks = _fakers.MusicTrack.Generate(elementCount); - var newTrackTitles = _fakers.MusicTrack.Generate(elementCount).Select(musicTrack => musicTrack.Title).ToArray(); + var existingTrack = _fakers.MusicTrack.Generate(); + var newTitle = _fakers.MusicTrack.Generate().Title; await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.AddRange(existingTracks); + dbContext.MusicTracks.Add(existingTrack); await dbContext.SaveChangesAsync(); }); - var operationElements = new List(elementCount); - for (int index = 0; index < elementCount; index++) + var requestBody = new { - operationElements.Add(new + atomic__operations = new[] { - op = "update", - data = new + new { - type = "musicTracks", - id = existingTracks[index].StringId, - attributes = new + op = "update", + data = new { - title = newTrackTitles[index] + type = "musicTracks", + id = existingTrack.StringId, + attributes = new + { + title = newTitle, + doesNotExist = "Ignored" + } } } - }); - } + } + }; + + var route = "/api/v1/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 = operationElements + 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 = "/api/v1/operations"; // Act @@ -136,31 +256,81 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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 => { - var tracksInDatabase = await dbContext.MusicTracks - .ToListAsync(); - - tracksInDatabase.Should().HaveCount(elementCount); + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); - for (int index = 0; index < elementCount; index++) + var requestBody = new + { + atomic__operations = new[] { - var trackInDatabase = tracksInDatabase.Single(musicTrack => musicTrack.Id == existingTracks[index].Id); - - trackInDatabase.Title.Should().Be(newTrackTitles[index]); - trackInDatabase.Genre.Should().Be(existingTracks[index].Genre); + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + attributes = new + { + genre = newGenre + } + } + } } + }; + + var route = "/api/v1/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_update_resource_without_attributes_or_relationships() + 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); @@ -180,9 +350,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingTrack.StringId, attributes = new { - }, - relationships = new - { + title = newTitle, + lengthInSeconds = newLengthInSeconds, + genre = newGenre, + releasedAt = newReleasedAt } } } @@ -201,22 +372,33 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - var tracksInDatabase = await dbContext.MusicTracks + var trackInDatabase = await dbContext.MusicTracks .Include(musicTrack => musicTrack.OwnedBy) .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); - tracksInDatabase.Title.Should().Be(existingTrack.Title); - tracksInDatabase.Genre.Should().Be(existingTrack.Genre); + trackInDatabase.Title.Should().Be(newTitle); + trackInDatabase.LengthInSeconds.Should().Be(newLengthInSeconds); + trackInDatabase.Genre.Should().Be(newGenre); + trackInDatabase.ReleasedAt.Should().BeCloseTo(newReleasedAt); - tracksInDatabase.OwnedBy.Should().NotBeNull(); - tracksInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); + trackInDatabase.OwnedBy.Should().NotBeNull(); + trackInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); }); } [Fact] - public async Task Cannot_update_resource_for_href_element() + 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[] @@ -224,7 +406,15 @@ public async Task Cannot_update_resource_for_href_element() new { op = "update", - href = "/api/v1/musicTracks/1" + data = new + { + type = "textLanguages", + id = existingLanguage.StringId, + attributes = new + { + isoCode = newIsoCode + } + } } } }; @@ -232,16 +422,1069 @@ public async Task Cannot_update_resource_for_href_element() var route = "/api/v1/operations"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - 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]"); + 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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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_type_in_data() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + id = 12345678, + attributes = new + { + }, + relationships = new + { + } + } + } + } + }; + + var route = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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_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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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); + }); } } } From 7bbe3aa72d9b9a48309ab25714bbc5ee43e98441 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 13 Jan 2021 22:01:01 +0100 Subject: [PATCH 049/123] Cleanup tests --- ...eateResourceWithToManyRelationshipTests.cs | 4 +- .../Deleting/AtomicDeleteResourceTests.cs | 18 ++++---- .../Mixed/AtomicLocalIdTests.cs | 4 +- .../Mixed/MixedOperationsTests.cs | 2 +- .../AtomicModelStateValidationStartup.cs | 3 +- .../AtomicModelStateValidationTests.cs | 16 +++---- .../AtomicAddToToManyRelationshipTests.cs | 35 ++++++++------- ...AtomicRemoveFromToManyRelationshipTests.cs | 31 +++++++------ .../AtomicReplaceToManyRelationshipTests.cs | 43 +++++++++---------- .../AtomicUpdateToOneRelationshipTests.cs | 43 +++++++++---------- 10 files changed, 98 insertions(+), 101 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs index 088cda8c8e..5f26cbeb44 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs @@ -544,7 +544,7 @@ public async Task Cannot_create_with_null_data_in_HasMany_relationship() { performers = new { - data = (object)null + data = (object) null } } } @@ -585,7 +585,7 @@ public async Task Cannot_create_with_null_data_in_HasManyThrough_relationship() { tracks = new { - data = (object)null + data = (object) null } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs index 5506a7ee67..aa79d7a6b1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs @@ -110,7 +110,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { atomic__operations = operationElements }; - + var route = "/api/v1/operations"; // Act @@ -173,7 +173,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { var lyricsInDatabase = await dbContext.Lyrics .FirstOrDefaultAsync(lyric => lyric.Id == existingLyric.Id); - + lyricsInDatabase.Should().BeNull(); var trackInDatabase = await dbContext.MusicTracks @@ -189,7 +189,7 @@ public async Task Can_delete_resource_with_OneToOne_relationship_from_dependent_ // Arrange var existingTrack = _fakers.MusicTrack.Generate(); existingTrack.Lyric = _fakers.Lyric.Generate(); - + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.MusicTracks.Add(existingTrack); @@ -226,7 +226,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { var tracksInDatabase = await dbContext.MusicTracks .FirstOrDefaultAsync(musicTrack => musicTrack.Id == existingTrack.Id); - + tracksInDatabase.Should().BeNull(); var lyricInDatabase = await dbContext.Lyrics @@ -242,7 +242,7 @@ 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); @@ -298,7 +298,7 @@ public async Task Can_delete_existing_resource_with_HasManyThrough_relationship( Playlist = _fakers.Playlist.Generate(), MusicTrack = _fakers.MusicTrack.Generate() }; - + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.PlaylistMusicTracks.Add(existingPlaylistMusicTrack); @@ -474,7 +474,7 @@ public async Task Cannot_delete_resource_for_unknown_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() { @@ -549,7 +549,7 @@ public async Task Cannot_delete_resource_for_incompatible_ID() { // Arrange var guid = Guid.NewGuid().ToString(); - + var requestBody = new { atomic__operations = new[] @@ -570,7 +570,7 @@ public async Task Cannot_delete_resource_for_incompatible_ID() // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - + // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs index 40a4e4fd75..8966548704 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs @@ -924,7 +924,7 @@ 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; @@ -1169,7 +1169,7 @@ 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; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs index 022330f7f5..770d2ee7c4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs @@ -85,7 +85,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }, } }; - + var route = "/api/v1/operations"; // Act diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationStartup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationStartup.cs index 1dfd367dd5..6130e7fb3d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationStartup.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationStartup.cs @@ -5,7 +5,8 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ModelS { public sealed class AtomicModelStateValidationStartup : TestableStartup { - public AtomicModelStateValidationStartup(IConfiguration configuration) : base(configuration) + public AtomicModelStateValidationStartup(IConfiguration configuration) + : base(configuration) { } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs index ad52dff9f1..62346d387a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs @@ -49,7 +49,7 @@ public async Task Cannot_create_resource_with_multiple_violations() } } }; - + var route = "/api/v1/operations"; // Act @@ -116,7 +116,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } } }; - + var route = "/api/v1/operations"; // Act @@ -173,7 +173,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } } }; - + var route = "/api/v1/operations"; // Act @@ -227,7 +227,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } } }; - + var route = "/api/v1/operations"; // Act @@ -290,7 +290,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } } }; - + var route = "/api/v1/operations"; // Act @@ -347,7 +347,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } } }; - + var route = "/api/v1/operations"; // Act @@ -406,7 +406,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } } }; - + var route = "/api/v1/operations"; // Act @@ -464,7 +464,7 @@ public async Task Validates_all_operations_before_execution_starts() } } }; - + var route = "/api/v1/operations"; // Act diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs index c782035a25..0ee28e1d67 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs @@ -18,8 +18,7 @@ public sealed class AtomicAddToToManyRelationshipTests private readonly IntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicAddToToManyRelationshipTests( - IntegrationTestContext, OperationsDbContext> testContext) + public AtomicAddToToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; @@ -78,16 +77,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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); @@ -175,7 +174,7 @@ public async Task Can_add_to_HasManyThrough_relationship() }; var existingTracks = _fakers.MusicTrack.Generate(2); - + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.Playlists.Add(existingPlaylist); @@ -510,7 +509,7 @@ public async Task Cannot_add_for_missing_relationship_in_ref() 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() { @@ -546,19 +545,19 @@ public async Task Cannot_add_for_unknown_relationship_in_ref() 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[] @@ -572,7 +571,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingTrack.StringId, relationship = "performers" }, - data = (object)null + data = (object) null } } }; @@ -714,7 +713,7 @@ public async Task Cannot_add_for_missing_ID_in_data() // 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."); @@ -759,7 +758,7 @@ public async Task Cannot_add_for_ID_and_local_ID_in_data() // 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."); @@ -773,13 +772,13 @@ 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[] @@ -824,7 +823,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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."); @@ -836,13 +835,13 @@ 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[] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs index 0c50563601..16bf84d2aa 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs @@ -18,8 +18,7 @@ public sealed class AtomicRemoveFromToManyRelationshipTests private readonly IntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicRemoveFromToManyRelationshipTests( - IntegrationTestContext, OperationsDbContext> testContext) + public AtomicRemoveFromToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; @@ -78,7 +77,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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() { @@ -398,13 +397,13 @@ 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[] @@ -517,19 +516,19 @@ public async Task Cannot_remove_for_unknown_relationship_in_ref() 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[] @@ -543,7 +542,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingTrack.StringId, relationship = "performers" }, - data = (object)null + data = (object) null } } }; @@ -685,7 +684,7 @@ public async Task Cannot_remove_for_missing_ID_in_data() // 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."); @@ -730,7 +729,7 @@ public async Task Cannot_remove_for_ID_and_local_ID_in_data() // 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."); @@ -744,13 +743,13 @@ 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[] @@ -795,7 +794,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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."); @@ -807,13 +806,13 @@ 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[] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs index 230592ffd2..84b97e1b43 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs @@ -18,8 +18,7 @@ public sealed class AtomicReplaceToManyRelationshipTests private readonly IntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicReplaceToManyRelationshipTests( - IntegrationTestContext, OperationsDbContext> testContext) + public AtomicReplaceToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; @@ -36,7 +35,7 @@ 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(); @@ -79,7 +78,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); trackInDatabase.Performers.Should().BeEmpty(); - + var performersInDatabase = await dbContext.Performers.ToListAsync(); performersInDatabase.Should().HaveCount(2); }); @@ -145,7 +144,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .FirstAsync(playlist => playlist.Id == existingPlaylist.Id); playlistInDatabase.PlaylistMusicTracks.Should().BeEmpty(); - + var tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); tracksInDatabase.Should().HaveCount(2); }); @@ -157,9 +156,9 @@ 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(); @@ -217,7 +216,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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); }); @@ -296,7 +295,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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); }); @@ -444,13 +443,13 @@ 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[] @@ -563,19 +562,19 @@ public async Task Cannot_replace_for_unknown_relationship_in_ref() 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[] @@ -589,7 +588,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingTrack.StringId, relationship = "performers" }, - data = (object)null + data = (object) null } } }; @@ -731,7 +730,7 @@ public async Task Cannot_replace_for_missing_ID_in_data() // 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."); @@ -776,7 +775,7 @@ public async Task Cannot_replace_for_ID_and_local_ID_in_data() // 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."); @@ -790,13 +789,13 @@ 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[] @@ -841,7 +840,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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."); @@ -853,13 +852,13 @@ 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[] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs index 971f927c72..479c7fb00a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs @@ -17,8 +17,7 @@ public sealed class AtomicUpdateToOneRelationshipTests private readonly IntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicUpdateToOneRelationshipTests( - IntegrationTestContext, OperationsDbContext> testContext) + public AtomicUpdateToOneRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; @@ -35,7 +34,7 @@ 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(); @@ -56,7 +55,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingLyric.StringId, relationship = "track" }, - data = (object)null + data = (object) null } } }; @@ -78,7 +77,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .FirstAsync(lyric => lyric.Id == existingLyric.Id); lyricInDatabase.Track.Should().BeNull(); - + var tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); tracksInDatabase.Should().HaveCount(1); }); @@ -90,7 +89,7 @@ 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(); @@ -111,7 +110,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingTrack.StringId, relationship = "lyric" }, - data = (object)null + data = (object) null } } }; @@ -133,7 +132,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); trackInDatabase.Lyric.Should().BeNull(); - + var lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); lyricsInDatabase.Should().HaveCount(1); }); @@ -166,7 +165,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => id = existingTrack.StringId, relationship = "ownedBy" }, - data = (object)null + data = (object) null } } }; @@ -188,7 +187,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => .FirstAsync(musicTrack => musicTrack.Id == existingTrack.Id); trackInDatabase.OwnedBy.Should().BeNull(); - + var companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); companiesInDatabase.Should().HaveCount(1); }); @@ -200,7 +199,7 @@ 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); @@ -255,7 +254,7 @@ 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); @@ -365,9 +364,9 @@ 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(); @@ -426,9 +425,9 @@ 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(); @@ -487,7 +486,7 @@ 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 => @@ -808,7 +807,7 @@ public async Task Cannot_create_for_array_in_data() { // Arrange var existingTrack = _fakers.MusicTrack.Generate(); - + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.MusicTracks.Add(existingTrack); @@ -968,7 +967,7 @@ public async Task Cannot_create_for_missing_ID_in_data() // 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."); @@ -1010,7 +1009,7 @@ public async Task Cannot_create_for_ID_and_local_ID_in_data() // 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."); @@ -1023,7 +1022,7 @@ 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); @@ -1072,7 +1071,7 @@ 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); From 5ce2218255fe5098d8a61d82965aa92a92980190 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 13 Jan 2021 22:40:17 +0100 Subject: [PATCH 050/123] Added tests for error cases --- .../Serialization/JsonApiReader.cs | 25 +++- .../Serialization/RequestDeserializer.cs | 5 +- ...reateResourceWithClientGeneratedIdTests.cs | 2 +- ...reateResourceWithToOneRelationshipTests.cs | 2 +- .../Mixed/MixedOperationsTests.cs | 137 +++++++++++++++++- .../CompositeKeys/CompositeKeyTests.cs | 2 +- 6 files changed, 152 insertions(+), 21 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index 07fa37ed47..2c3705b596 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs @@ -67,7 +67,11 @@ public async Task ReadAsync(InputFormatterContext context) } } - if (_request.Kind != EndpointKind.AtomicOperations && 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); } @@ -115,13 +119,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); @@ -137,6 +135,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/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index 9d41ea2898..6f70e6dbe1 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -68,10 +68,7 @@ private object DeserializeOperationsDocument(string body) if (document?.Operations == null || !document.Operations.Any()) { - throw new JsonApiException(new Error(HttpStatusCode.BadRequest) - { - Title = "Failed to deserialize operations request." - }); + throw new JsonApiSerializationException("No operations found.", null); } AtomicOperationIndex = 0; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs index d88310d5ab..953aab7c11 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -104,7 +104,7 @@ public async Task Can_create_resource_with_client_generated_string_ID_having_no_ attributes = new { title = newTrack.Title, - lengthInSeconds = newTrack.LengthInSeconds, + lengthInSeconds = newTrack.LengthInSeconds } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs index 7901e42b76..ca6fdc0e93 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs @@ -522,7 +522,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => type = "recordCompanies", id = existingCompany.StringId } - }, + } } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs index 770d2ee7c4..3b1c1380b1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs @@ -27,6 +27,136 @@ public MixedOperationsTests(IntegrationTestContext(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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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(); + }); + } + [Fact] public async Task Can_rollback_on_error() { @@ -82,7 +212,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } } } - }, + } } }; @@ -109,10 +239,5 @@ await _testContext.RunOnDatabaseAsync(async dbContext => tracksInDatabase.Should().BeEmpty(); }); } - - // TODO: @OPS: Cannot_process_empty_operations_array - // TODO: @OPS: Cannot_process_operations_for_missing_request_body - // TODO: @OPS: Cannot_process_operations_for_broken_JSON_request_body - // TODO: @OPS: Cannot_process_operations_for_unknown_operation_code } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index b55f9ab1b0..c42ff8c162 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -503,7 +503,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 => From 1b00d3496bb3c767bf49c61cb75838bd66059f20 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 13 Jan 2021 23:01:08 +0100 Subject: [PATCH 051/123] Fixed: Both id and lid in create resource operation should not be allowed --- .../Serialization/RequestDeserializer.cs | 24 ++++++++----- ...reateResourceWithClientGeneratedIdTests.cs | 36 +++++++++++++++++++ 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index 6f70e6dbe1..d76b94bcc4 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -113,8 +113,11 @@ private OperationContainer DeserializeOperation(AtomicOperationObject operation) } resourceContextInRef = GetExistingResourceContext(operation.Ref.Type); - - if ((operation.Ref.Id == null && operation.Ref.Lid == null) || (operation.Ref.Id != null && operation.Ref.Lid != null)) + + bool hasNone = operation.Ref.Id == null && operation.Ref.Lid == null; + bool hasBoth = operation.Ref.Id != null && operation.Ref.Lid != null; + + if (hasNone || hasBoth) { throw new JsonApiSerializationException("The 'ref.id' or 'ref.lid' element is required.", null, atomicOperationIndex: AtomicOperationIndex); @@ -185,7 +188,10 @@ private OperationContainer DeserializeOperation(AtomicOperationObject operation) atomicOperationIndex: AtomicOperationIndex); } - if ((resourceObject.Id == null && resourceObject.Lid == null) || (resourceObject.Id != null && resourceObject.Lid != null)) + bool hasNone = resourceObject.Id == null && resourceObject.Lid == null; + bool hasBoth = resourceObject.Id != null && resourceObject.Lid != null; + + if (hasNone || hasBoth) { throw new JsonApiSerializationException("The 'data[].id' or 'data[].lid' element is required.", null, atomicOperationIndex: AtomicOperationIndex); @@ -213,13 +219,13 @@ private OperationContainer DeserializeOperation(AtomicOperationObject operation) atomicOperationIndex: AtomicOperationIndex); } - if (kind != OperationKind.CreateResource) + bool hasNone = resourceObject.Id == null && resourceObject.Lid == null; + bool hasBoth = resourceObject.Id != null && resourceObject.Lid != null; + + if (kind == OperationKind.CreateResource ? hasBoth : hasNone || hasBoth) { - if ((resourceObject.Id == null && resourceObject.Lid == null) || (resourceObject.Id != null && resourceObject.Lid != null)) - { - throw new JsonApiSerializationException("The 'data.id' or 'data.lid' element is required.", null, - atomicOperationIndex: AtomicOperationIndex); - } + throw new JsonApiSerializationException("The 'data.id' or 'data.lid' element is required.", null, + atomicOperationIndex: AtomicOperationIndex); } var resourceContextInData = GetExistingResourceContext(resourceObject.Type); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs index 953aab7c11..e2cc2bd824 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -182,5 +182,41 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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_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 = "/api/v1/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]"); + } } } From 6dc7e43eb37b21a111cd34fdd019911a3c11a753 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 13 Jan 2021 23:35:10 +0100 Subject: [PATCH 052/123] Added option: MaximumOperationsPerRequest --- .../JsonApiDeserializerBenchmarks.cs | 2 +- .../Configuration/IJsonApiOptions.cs | 6 + .../Configuration/JsonApiOptions.cs | 3 + .../Serialization/RequestDeserializer.cs | 11 +- .../Mixed/MaximumOperationsPerRequestTests.cs | 165 ++++++++++++++++++ .../Models/ResourceConstructionTests.cs | 24 ++- .../Server/RequestDeserializerTests.cs | 3 +- 7 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs diff --git a/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs b/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs index a77496f6c4..f438d424af 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/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index 96717fe796..3d97b0cd3e 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -172,6 +172,12 @@ 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; } + /// /// 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/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index c999508574..a820acbd3a 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -67,6 +67,9 @@ public sealed class JsonApiOptions : IJsonApiOptions /// public int? MaximumIncludeDepth { get; set; } + /// + public int? MaximumOperationsPerRequest { get; set; } = 10; + /// public JsonSerializerSettings SerializerSettings { get; } = new JsonSerializerSettings { diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index d76b94bcc4..f703b5a778 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -23,18 +23,21 @@ public class RequestDeserializer : BaseDeserializer, IJsonApiDeserializer 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)); } /// @@ -71,6 +74,12 @@ private object DeserializeOperationsDocument(string body) 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}."); + } + AtomicOperationIndex = 0; foreach (var operation in document.Operations) { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs new file mode 100644 index 0000000000..19130b5f68 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs @@ -0,0 +1,165 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Mixed +{ + public sealed class MaximumOperationsPerRequestTests + : IClassFixture, OperationsDbContext>> + { + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public MaximumOperationsPerRequestTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + }); + } + + [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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/operations"; + + // Act + var (httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + } +} diff --git a/test/UnitTests/Models/ResourceConstructionTests.cs b/test/UnitTests/Models/ResourceConstructionTests.cs index 55c00d0b3b..f68865d7be 100644 --- a/test/UnitTests/Models/ResourceConstructionTests.cs +++ b/test/UnitTests/Models/ResourceConstructionTests.cs @@ -31,11 +31,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 { @@ -60,11 +62,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 { @@ -91,7 +95,9 @@ public void When_resource_has_default_constructor_that_throws_it_must_fail() public void When_resource_has_constructor_with_injectable_parameter_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(); @@ -100,7 +106,7 @@ public void When_resource_has_constructor_with_injectable_parameter_it_must_succ var serviceContainer = new ServiceContainer(); serviceContainer.AddService(typeof(AppDbContext), appDbContext); - var serializer = new RequestDeserializer(graph, new ResourceFactory(serviceContainer), new TargetedFields(), _mockHttpContextAccessor.Object, _requestMock.Object); + var serializer = new RequestDeserializer(graph, new ResourceFactory(serviceContainer), new TargetedFields(), _mockHttpContextAccessor.Object, _requestMock.Object, options); var body = new { @@ -126,11 +132,13 @@ public void When_resource_has_constructor_with_injectable_parameter_it_must_succ 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 abe76ea3f6..cf7bba316d 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] From de58379bada47c9dc22c293f242b01aa7d465d1a Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 14 Jan 2021 12:13:18 +0100 Subject: [PATCH 053/123] Added operation tests for Update resource with to-one/to-many relationship --- .../AtomicReplaceToManyRelationshipTests.cs | 695 +++++++++++++ .../AtomicUpdateToOneRelationshipTests.cs | 937 ++++++++++++++++++ 2 files changed, 1632 insertions(+) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs 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..8acfe7eb93 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs @@ -0,0 +1,695 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Resources +{ + public sealed class AtomicReplaceToManyRelationshipTests + : IClassFixture, OperationsDbContext>> + { + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicReplaceToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + }); + } + + [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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs new file mode 100644 index 0000000000..e489e38862 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs @@ -0,0 +1,937 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Resources +{ + public sealed class AtomicUpdateToOneRelationshipTests + : IClassFixture, OperationsDbContext>> + { + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicUpdateToOneRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + }); + } + + [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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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]"); + } + } +} From b43fe8e45c291eaadb542128bba24e61461c4de2 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 15 Jan 2021 11:54:01 +0100 Subject: [PATCH 054/123] Fixed links rendering in operation responses --- .../Middleware/JsonApiMiddleware.cs | 27 +-- .../AtomicOperationsResponseSerializer.cs | 9 +- .../Serialization/RequestDeserializer.cs | 4 +- .../Links/AtomicLinksTests.cs | 179 ++++++++++++++++++ .../Links/NeverSameResourceChangeTracker.cs | 25 +++ 5 files changed, 227 insertions(+), 17 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicLinksTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/NeverSameResourceChangeTracker.cs diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index ecac1556c8..7431253e0f 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -68,7 +68,7 @@ public async Task Invoke(HttpContext httpContext, return; } - SetupAtomicOperationsRequest((JsonApiRequest)request); + SetupAtomicOperationsRequest((JsonApiRequest)request, options, httpContext.Request); httpContext.RegisterJsonApiRequest(); } @@ -238,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; @@ -269,13 +272,11 @@ private static bool IsAtomicOperationsRequest(RouteValueDictionary routeValues) return actionName == "PostOperations"; } - private static void SetupAtomicOperationsRequest(JsonApiRequest request) + private static void SetupAtomicOperationsRequest(JsonApiRequest request, IJsonApiOptions options, HttpRequest httpRequest) { request.IsReadOnly = false; request.Kind = EndpointKind.AtomicOperations; - - // TODO: @OPS: How should we set BasePath to make link rendering work? - request.BasePath = null; + request.BasePath = GetBasePath(null, options, httpRequest); } } } diff --git a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs index 88e58bd69d..babc41a70b 100644 --- a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs @@ -15,15 +15,17 @@ namespace JsonApiDotNetCore.Serialization /// public sealed class AtomicOperationsResponseSerializer : BaseSerializer, IJsonApiSerializer { + private readonly ILinkBuilder _linkBuilder; private readonly IResourceContextProvider _resourceContextProvider; private readonly IJsonApiOptions _options; public string ContentType { get; } = HeaderConstants.AtomicOperationsMediaType; - public AtomicOperationsResponseSerializer(IResourceObjectBuilder resourceObjectBuilder, + public AtomicOperationsResponseSerializer(IResourceObjectBuilder resourceObjectBuilder, ILinkBuilder linkBuilder, IResourceContextProvider resourceContextProvider, IJsonApiOptions options) : base(resourceObjectBuilder) { + _linkBuilder = linkBuilder ?? throw new ArgumentNullException(nameof(linkBuilder)); _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); _options = options ?? throw new ArgumentNullException(nameof(options)); } @@ -60,12 +62,17 @@ private string SerializeOperationsDocument(IEnumerable resources) // TODO: @OPS: Should inject IFieldsToSerialize, which uses SparseFieldSetCache to call into resource definitions to hide fields. // But then we need to update IJsonApiRequest for each loop entry, which we don't have access to anymore. + // That would be more correct, because ILinkBuilder depends on IJsonApiRequest too. var attributes = resourceContext.Attributes .Where(attr => attr.Capabilities.HasFlag(AttrCapabilities.AllowView)) .ToArray(); resourceObject = ResourceObjectBuilder.Build(resource, attributes, resourceContext.Relationships); + if (resourceObject != null) + { + resourceObject.Links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); + } } document.Results.Add(new AtomicResultObject diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index f703b5a778..c4ea04002d 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -1,11 +1,9 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net; using System.Net.Http; using Humanizer; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -338,7 +336,7 @@ private OperationContainer ToOperationContainer(AtomicOperationObject operation, Kind = EndpointKind.AtomicOperations, OperationKind = kind, PrimaryId = resource.StringId, - BasePath = "TODO: Set this...", + BasePath = _request.BasePath, PrimaryResource = primaryResourceContext }; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicLinksTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicLinksTests.cs new file mode 100644 index 0000000000..bd2d162a8c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicLinksTests.cs @@ -0,0 +1,179 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Links +{ + public sealed class AtomicLinksTests + : IClassFixture, OperationsDbContext>> + { + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicLinksTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); + } + + [Fact] + public async Task Create_resource_with_side_effects_returns_relative_links() + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.Namespace = "api"; + options.UseRelativeLinks = true; + + 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/v1/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"); + } + + [Fact] + public async Task Update_resource_with_side_effects_returns_absolute_links() + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.Namespace = null; + options.UseRelativeLinks = false; + + 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 = "/api/v1/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/NeverSameResourceChangeTracker.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/NeverSameResourceChangeTracker.cs new file mode 100644 index 0000000000..34835057ad --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/NeverSameResourceChangeTracker.cs @@ -0,0 +1,25 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Links +{ + internal sealed class NeverSameResourceChangeTracker : IResourceChangeTracker + where TResource : class, IIdentifiable + { + public void SetInitiallyStoredAttributeValues(TResource resource) + { + } + + public void SetRequestedAttributeValues(TResource resource) + { + } + + public void SetFinallyStoredAttributeValues(TResource resource) + { + } + + public bool HasImplicitChanges() + { + return true; + } + } +} From cf01dbadc45cd71277c0f07b68381b820ab51cac Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 15 Jan 2021 23:50:39 +0100 Subject: [PATCH 055/123] Separated transaction support from operations processing. --- .../AtomicOperationsProcessor.cs | 36 +---- .../EntityFrameworkCoreTransaction.cs | 53 ++++++ .../EntityFrameworkCoreTransactionFactory.cs | 28 ++++ .../IAtomicOperationsProcessor.cs | 3 + .../IAtomicOperationsTransaction.cs | 32 ++++ .../IAtomicOperationsTransactionFactory.cs | 16 ++ .../MissingTransactionFactory.cs | 17 ++ .../JsonApiApplicationBuilder.cs | 6 + .../MissingTransactionSupportException.cs | 21 +++ .../Errors/MultipleTransactionsException.cs | 21 +++ .../Middleware/IJsonApiRequest.cs | 6 + .../Middleware/JsonApiRequest.cs | 4 + .../EntityFrameworkCoreRepository.cs | 9 +- .../IRepositorySupportsTransaction.cs | 15 ++ .../ResourceRepositoryAccessor.cs | 28 +++- .../Resources/OperationContainer.cs | 5 + .../Transactions/ExtraDbContext.cs | 12 ++ .../Transactions/LyricRepository.cs | 28 ++++ .../Transactions/MusicTrackRepository.cs | 22 +++ .../Transactions/PerformerRepository.cs | 65 ++++++++ .../TransactionConsistencyTests.cs | 151 ++++++++++++++++++ 21 files changed, 546 insertions(+), 32 deletions(-) create mode 100644 src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs create mode 100644 src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs create mode 100644 src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsTransaction.cs create mode 100644 src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsTransactionFactory.cs create mode 100644 src/JsonApiDotNetCore/AtomicOperations/MissingTransactionFactory.cs create mode 100644 src/JsonApiDotNetCore/Errors/MissingTransactionSupportException.cs create mode 100644 src/JsonApiDotNetCore/Errors/MultipleTransactionsException.cs create mode 100644 src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/ExtraDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/TransactionConsistencyTests.cs diff --git a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs index 63fbee5036..1aa07f89c9 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs @@ -1,16 +1,13 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Net; using System.Threading; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.EntityFrameworkCore; namespace JsonApiDotNetCore.AtomicOperations { @@ -22,30 +19,21 @@ public class AtomicOperationsProcessor : IAtomicOperationsProcessor private readonly IJsonApiRequest _request; private readonly ITargetedFields _targetedFields; private readonly IResourceContextProvider _resourceContextProvider; - private readonly DbContext _dbContext; + private readonly IAtomicOperationsTransactionFactory _atomicOperationsTransactionFactory; public AtomicOperationsProcessor(IAtomicOperationProcessorResolver resolver, ILocalIdTracker localIdTracker, IJsonApiRequest request, ITargetedFields targetedFields, - IResourceContextProvider resourceContextProvider, IEnumerable dbContextResolvers) + IResourceContextProvider resourceContextProvider, IAtomicOperationsTransactionFactory atomicOperationsTransactionFactory) { - if (dbContextResolvers == null) throw new ArgumentNullException(nameof(dbContextResolvers)); - _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); _localIdTracker = localIdTracker ?? throw new ArgumentNullException(nameof(localIdTracker)); _request = request ?? throw new ArgumentNullException(nameof(request)); _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); - - var resolvers = dbContextResolvers.ToArray(); - if (resolvers.Length != 1) - { - throw new InvalidOperationException( - "TODO: @OPS: At least one DbContext is required for atomic operations. Multiple DbContexts are currently not supported."); - } - - _dbContext = resolvers[0].GetContext(); + _atomicOperationsTransactionFactory = atomicOperationsTransactionFactory ?? throw new ArgumentNullException(nameof(atomicOperationsTransactionFactory)); } + /// public async Task> ProcessAsync(IList operations, CancellationToken cancellationToken) { if (operations == null) throw new ArgumentNullException(nameof(operations)); @@ -54,12 +42,14 @@ public async Task> ProcessAsync(IList o var results = new List(); - await using var transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken); + await using var transaction = await _atomicOperationsTransactionFactory.BeginTransactionAsync(cancellationToken); try { foreach (var operation in operations) { - _dbContext.ResetChangeTracker(); + operation.SetTransactionId(transaction.TransactionId); + + transaction.PrepareForNextOperation(); var result = await ProcessOperation(operation, cancellationToken); results.Add(result); @@ -74,11 +64,6 @@ public async Task> ProcessAsync(IList o } catch (JsonApiException exception) { - if (_dbContext.Database.CurrentTransaction != null) - { - await transaction.RollbackAsync(cancellationToken); - } - foreach (var error in exception.Errors) { error.Source.Pointer = $"/atomic:operations[{results.Count}]" + error.Source.Pointer; @@ -88,11 +73,6 @@ public async Task> ProcessAsync(IList o } catch (Exception exception) { - if (_dbContext.Database.CurrentTransaction != null) - { - await transaction.RollbackAsync(cancellationToken); - } - throw new JsonApiException(new Error(HttpStatusCode.InternalServerError) { Title = "An unhandled error occurred while processing an operation in this request.", diff --git a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs new file mode 100644 index 0000000000..45a7c65619 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs @@ -0,0 +1,53 @@ +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 : IAtomicOperationsTransaction + { + 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 void PrepareForNextOperation() + { + _dbContext.ResetChangeTracker(); + } + + /// + public Task CommitAsync(CancellationToken cancellationToken) + { + return _transaction.CommitAsync(cancellationToken); + } + + /// + public Task RollbackAsync(CancellationToken cancellationToken) + { + return _transaction.RollbackAsync(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..267fff1b1b --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Repositories; + +namespace JsonApiDotNetCore.AtomicOperations +{ + /// + /// Provides transaction support for atomic:operation requests using Entity Framework Core. + /// + public sealed class EntityFrameworkCoreTransactionFactory : IAtomicOperationsTransactionFactory + { + private readonly IDbContextResolver _dbContextResolver; + + public EntityFrameworkCoreTransactionFactory(IDbContextResolver dbContextResolver) + { + _dbContextResolver = dbContextResolver ?? throw new ArgumentNullException(nameof(dbContextResolver)); + } + + /// + public async Task BeginTransactionAsync(CancellationToken cancellationToken) + { + var dbContext = _dbContextResolver.GetContext(); + var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + return new EntityFrameworkCoreTransaction(transaction, dbContext); + } + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsProcessor.cs index 1662982749..9770fb14d6 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsProcessor.cs @@ -10,6 +10,9 @@ namespace JsonApiDotNetCore.AtomicOperations /// public interface IAtomicOperationsProcessor { + /// + /// Processes the list of specified operations. + /// Task> ProcessAsync(IList operations, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsTransaction.cs b/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsTransaction.cs new file mode 100644 index 0000000000..44ad21d58e --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsTransaction.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 IAtomicOperationsTransaction : IAsyncDisposable + { + /// + /// Identifies the active transaction. + /// + Guid TransactionId { get; } + + /// + /// Enables to execute custom logic before processing of the next operation starts. + /// + void PrepareForNextOperation(); + + /// + /// Commits all changes made to the underlying data store. + /// + Task CommitAsync(CancellationToken cancellationToken); + + /// + /// Discards all changes made to the underlying data store. + /// + Task RollbackAsync(CancellationToken cancellationToken); + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsTransactionFactory.cs b/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsTransactionFactory.cs new file mode 100644 index 0000000000..8f178c7b31 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsTransactionFactory.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 IAtomicOperationsTransactionFactory + { + /// + /// Starts a new transaction. + /// + Task BeginTransactionAsync(CancellationToken cancellationToken); + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/MissingTransactionFactory.cs b/src/JsonApiDotNetCore/AtomicOperations/MissingTransactionFactory.cs new file mode 100644 index 0000000000..56ceaf9088 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/MissingTransactionFactory.cs @@ -0,0 +1,17 @@ +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 : IAtomicOperationsTransactionFactory + { + public Task BeginTransactionAsync(CancellationToken cancellationToken) + { + throw new NotImplementedException("No transaction support is available."); + } + } +} diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 43f195426f..a903600f71 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -132,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(); 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/MultipleTransactionsException.cs b/src/JsonApiDotNetCore/Errors/MultipleTransactionsException.cs new file mode 100644 index 0000000000..094ebc167d --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/MultipleTransactionsException.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 MultipleTransactionsException : JsonApiException + { + public MultipleTransactionsException() + : 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/Middleware/IJsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs index 39677ebbe7..0feb09bc47 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; @@ -63,6 +64,11 @@ public interface IJsonApiRequest /// OperationKind? OperationKind { get; } + /// + /// In case of an atomic:operations request, identifies the overarching transaction. + /// + Guid? TransactionId { get; } + /// /// Performs a shallow copy. /// diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs index 28ed7f879c..b4776419e3 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs @@ -34,6 +34,9 @@ public sealed class JsonApiRequest : IJsonApiRequest /// public OperationKind? OperationKind { get; set; } + /// + public Guid? TransactionId { get; set; } + /// public void CopyFrom(IJsonApiRequest other) { @@ -48,6 +51,7 @@ public void CopyFrom(IJsonApiRequest other) IsCollection = other.IsCollection; IsReadOnly = other.IsReadOnly; OperationKind = other.OperationKind; + TransactionId = other.TransactionId; } } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index beeb921a70..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, @@ -401,8 +404,8 @@ protected virtual async Task SaveChangesAsync(CancellationToken cancellationToke { if (_dbContext.Database.CurrentTransaction != null) { - // The ResourceService calling us needs to run additional SQL queries - // after an aborted transaction, to determine error cause. + // 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); } diff --git a/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs b/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs new file mode 100644 index 0000000000..0fc893266e --- /dev/null +++ b/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs @@ -0,0 +1,15 @@ +using System; + +namespace JsonApiDotNetCore.Repositories +{ + /// + /// Used to indicate a supports execution inside a transaction. + /// + public interface IRepositorySupportsTransaction + { + /// + /// Identifies the currently active transaction. + /// + Guid? TransactionId { get; } + } +} diff --git a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs index 61c64633a3..ee7a6a2dc9 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)); } /// @@ -131,6 +135,28 @@ protected virtual object ResolveReadRepository(Type resourceType) } protected virtual object ResolveWriteRepository(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 MultipleTransactionsException(); + } + } + + return writeRepository; + } + + private object ResolveWriteRepository(Type resourceType) { var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); diff --git a/src/JsonApiDotNetCore/Resources/OperationContainer.cs b/src/JsonApiDotNetCore/Resources/OperationContainer.cs index bc556f4911..4f6bd74f6e 100644 --- a/src/JsonApiDotNetCore/Resources/OperationContainer.cs +++ b/src/JsonApiDotNetCore/Resources/OperationContainer.cs @@ -21,5 +21,10 @@ public OperationContainer(OperationKind kind, IIdentifiable resource, ITargetedF TargetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); Request = request ?? throw new ArgumentNullException(nameof(request)); } + + public void SetTransactionId(Guid transactionId) + { + ((JsonApiRequest) Request).TransactionId = transactionId; + } } } 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/Transactions/TransactionConsistencyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/TransactionConsistencyTests.cs new file mode 100644 index 0000000000..6fdafae473 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/TransactionConsistencyTests.cs @@ -0,0 +1,151 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Transactions +{ + public sealed class TransactionConsistencyTests + : IClassFixture, OperationsDbContext>> + { + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + + public TransactionConsistencyTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + + 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 = "/api/v1/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 = "/api/v1/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 = "/api/v1/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]"); + } + } +} From 82d3dee21d24d34800b8af828e7513807ae333aa Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 18 Jan 2021 16:26:36 +0100 Subject: [PATCH 056/123] Automatic routing setup for operations controller --- ...sController.cs => OperationsController.cs} | 8 +- ....cs => BaseJsonApiOperationsController.cs} | 8 +- ...ller.cs => JsonApiOperationsController.cs} | 14 +- .../Middleware/JsonApiRoutingConvention.cs | 26 ++- .../AtomicCreateMusicTrackTests.cs | 214 ++++++++++++++++++ .../CreateMusicTrackOperationsController.cs | 56 +++++ .../Creating/AtomicCreateResourceTests.cs | 30 +-- ...reateResourceWithClientGeneratedIdTests.cs | 8 +- ...eateResourceWithToManyRelationshipTests.cs | 20 +- ...reateResourceWithToOneRelationshipTests.cs | 20 +- .../Deleting/AtomicDeleteResourceTests.cs | 28 +-- ...ksTests.cs => AtomicAbsoluteLinksTests.cs} | 82 +------ .../AtomicRelativeLinksWithNamespaceTests.cs | 99 ++++++++ .../Mixed/AtomicLocalIdTests.cs | 56 ++--- .../Mixed/MaximumOperationsPerRequestTests.cs | 6 +- .../Mixed/MixedOperationsTests.cs | 10 +- .../AtomicModelStateValidationStartup.cs | 20 -- .../AtomicModelStateValidationTests.cs | 22 +- .../QueryStrings/AtomicQueryStringTests.cs | 18 +- .../TransactionConsistencyTests.cs | 6 +- .../AtomicAddToToManyRelationshipTests.cs | 38 ++-- ...AtomicRemoveFromToManyRelationshipTests.cs | 36 +-- .../AtomicReplaceToManyRelationshipTests.cs | 36 +-- .../AtomicUpdateToOneRelationshipTests.cs | 46 ++-- .../AtomicReplaceToManyRelationshipTests.cs | 22 +- .../Resources/AtomicUpdateResourceTests.cs | 58 ++--- .../AtomicUpdateToOneRelationshipTests.cs | 32 +-- .../ContentNegotiation/AcceptHeaderTests.cs | 6 +- .../ContentTypeHeaderTests.cs | 6 +- .../ModelStateValidationStartup.cs | 5 +- .../RelativeApiNamespaceStartup.cs | 23 ++ 31 files changed, 678 insertions(+), 381 deletions(-) rename src/Examples/JsonApiDotNetCoreExample/Controllers/{AtomicOperationsController.cs => OperationsController.cs} (56%) rename src/JsonApiDotNetCore/Controllers/{BaseJsonApiAtomicOperationsController.cs => BaseJsonApiOperationsController.cs} (94%) rename src/JsonApiDotNetCore/Controllers/{JsonApiAtomicOperationsController.cs => JsonApiOperationsController.cs} (61%) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/AtomicCreateMusicTrackTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/{AtomicLinksTests.cs => AtomicAbsoluteLinksTests.cs} (55%) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationStartup.cs rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{ModelStateValidation => }/ModelStateValidationStartup.cs (83%) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/RelativeApiNamespaceStartup.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/AtomicOperationsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs similarity index 56% rename from src/Examples/JsonApiDotNetCoreExample/Controllers/AtomicOperationsController.cs rename to src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs index b2ae122808..4a146ce58c 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/AtomicOperationsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs @@ -1,19 +1,15 @@ using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Controllers.Annotations; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; -using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCoreExample.Controllers { - // TODO: @OPS: Apply route based on configured namespace. - [DisableRoutingConvention, Route("/api/v1/operations")] - public class AtomicOperationsController : JsonApiAtomicOperationsController + public sealed class OperationsController : JsonApiOperationsController { - public AtomicOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + public OperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IAtomicOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) : base(options, loggerFactory, processor, request, targetedFields) { diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs similarity index 94% rename from src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs rename to src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs index b734c4113c..fdd5bfa00f 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiAtomicOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs @@ -17,15 +17,15 @@ 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 BaseJsonApiAtomicOperationsController : CoreJsonApiController + public abstract class BaseJsonApiOperationsController : CoreJsonApiController { private readonly IJsonApiOptions _options; private readonly IAtomicOperationsProcessor _processor; private readonly IJsonApiRequest _request; private readonly ITargetedFields _targetedFields; - private readonly TraceLogWriter _traceWriter; + private readonly TraceLogWriter _traceWriter; - protected BaseJsonApiAtomicOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + protected BaseJsonApiOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IAtomicOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) { if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); @@ -34,7 +34,7 @@ protected BaseJsonApiAtomicOperationsController(IJsonApiOptions options, ILogger _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); + _traceWriter = new TraceLogWriter(loggerFactory); } /// diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiAtomicOperationsController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs similarity index 61% rename from src/JsonApiDotNetCore/Controllers/JsonApiAtomicOperationsController.cs rename to src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs index ffe71facc8..7e010a5cb8 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiAtomicOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs @@ -12,18 +12,12 @@ 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 BaseJsonApiAtomicOperationsController directly. + /// 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. /// - /// - /// Your project-specific controller should be decorated with the next attributes: - /// - /// - public abstract class JsonApiAtomicOperationsController : BaseJsonApiAtomicOperationsController + public abstract class JsonApiOperationsController : BaseJsonApiOperationsController { - protected JsonApiAtomicOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + protected JsonApiOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IAtomicOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) : base(options, loggerFactory, processor, request, targetedFields) { diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs index 9a4fde58aa..4bc4df995b 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs @@ -63,18 +63,22 @@ 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); + } } } - + if (!RoutingConventionDisabled(controller)) { continue; @@ -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/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/AtomicCreateMusicTrackTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/AtomicCreateMusicTrackTests.cs new file mode 100644 index 0000000000..9ddfceee2a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/AtomicCreateMusicTrackTests.cs @@ -0,0 +1,214 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Controllers +{ + public sealed class AtomicCreateMusicTrackTests + : IClassFixture, OperationsDbContext>> + { + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicCreateMusicTrackTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + }); + } + + [Fact] + public async Task Can_create_MusicTrack_resources() + { + // 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_other_resource() + { + // 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_MusicTrack_resource() + { + // 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() + { + // 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..092eb43dfb --- /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, + IAtomicOperationsProcessor 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 index 8ac6f4fdd1..dbe62c332b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -58,7 +58,7 @@ public async Task Can_create_resource() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -118,7 +118,7 @@ public async Task Can_create_resources() atomic__operations = operationElements }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -186,7 +186,7 @@ public async Task Can_create_resource_without_attributes_or_relationships() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -239,7 +239,7 @@ public async Task Can_create_resource_with_unknown_attribute() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -294,7 +294,7 @@ public async Task Can_create_resource_with_unknown_relationship() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -345,7 +345,7 @@ public async Task Cannot_create_resource_with_client_generated_ID() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -376,7 +376,7 @@ public async Task Cannot_create_resource_for_href_element() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -410,7 +410,7 @@ public async Task Cannot_create_resource_for_ref_element() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -446,7 +446,7 @@ public async Task Cannot_create_resource_for_missing_type() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -480,7 +480,7 @@ public async Task Cannot_create_resource_for_unknown_type() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -523,7 +523,7 @@ public async Task Cannot_create_resource_for_array() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -561,7 +561,7 @@ public async Task Cannot_create_resource_attribute_with_blocked_capability() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -602,7 +602,7 @@ public async Task Cannot_create_resource_with_readonly_attribute() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -640,7 +640,7 @@ public async Task Cannot_create_resource_with_incompatible_attribute_value() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -720,7 +720,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs index e2cc2bd824..19bc564995 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -59,7 +59,7 @@ public async Task Can_create_resource_with_client_generated_guid_ID_having_side_ } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -111,7 +111,7 @@ public async Task Can_create_resource_with_client_generated_string_ID_having_no_ } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -168,7 +168,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -204,7 +204,7 @@ public async Task Cannot_create_resource_for_ID_and_local_ID() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs index 5f26cbeb44..28c03ce419 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs @@ -79,7 +79,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -163,7 +163,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -225,7 +225,7 @@ public async Task Cannot_create_for_missing_relationship_type() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -273,7 +273,7 @@ public async Task Cannot_create_for_unknown_relationship_type() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -320,7 +320,7 @@ public async Task Cannot_create_for_missing_relationship_ID() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -379,7 +379,7 @@ public async Task Cannot_create_for_unknown_relationship_IDs() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -433,7 +433,7 @@ public async Task Cannot_create_on_relationship_type_mismatch() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -499,7 +499,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -552,7 +552,7 @@ public async Task Cannot_create_with_null_data_in_HasMany_relationship() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -593,7 +593,7 @@ public async Task Cannot_create_with_null_data_in_HasManyThrough_relationship() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs index ca6fdc0e93..66bfe8a3f6 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs @@ -69,7 +69,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -139,7 +139,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -214,7 +214,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => atomic__operations = operationElements }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -284,7 +284,7 @@ public async Task Cannot_create_for_missing_relationship_type() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -329,7 +329,7 @@ public async Task Cannot_create_for_unknown_relationship_type() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -373,7 +373,7 @@ public async Task Cannot_create_for_missing_relationship_ID() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -418,7 +418,7 @@ public async Task Cannot_create_with_unknown_relationship_ID() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -463,7 +463,7 @@ public async Task Cannot_create_on_relationship_type_mismatch() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -531,7 +531,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var requestBodyText = JsonConvert.SerializeObject(requestBody).Replace("ownedBy_duplicate", "ownedBy"); - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBodyText); @@ -591,7 +591,7 @@ public async Task Cannot_create_with_data_array_in_relationship() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs index aa79d7a6b1..6c6af39eb1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs @@ -58,7 +58,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -111,7 +111,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => atomic__operations = operationElements }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -159,7 +159,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -212,7 +212,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -265,7 +265,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -321,7 +321,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -361,7 +361,7 @@ public async Task Cannot_delete_resource_for_href_element() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -391,7 +391,7 @@ public async Task Cannot_delete_resource_for_missing_ref_element() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -425,7 +425,7 @@ public async Task Cannot_delete_resource_for_missing_type() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -460,7 +460,7 @@ public async Task Cannot_delete_resource_for_unknown_type() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -494,7 +494,7 @@ public async Task Cannot_delete_resource_for_missing_ID() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -529,7 +529,7 @@ public async Task Cannot_delete_resource_for_unknown_ID() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -566,7 +566,7 @@ public async Task Cannot_delete_resource_for_incompatible_ID() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -602,7 +602,7 @@ public async Task Cannot_delete_resource_for_ID_and_local_ID() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicLinksTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs similarity index 55% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicLinksTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs index bd2d162a8c..7157ba06ed 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicLinksTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs @@ -1,8 +1,6 @@ -using System; using System.Net; using System.Threading.Tasks; using FluentAssertions; -using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; @@ -12,13 +10,13 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Links { - public sealed class AtomicLinksTests + public sealed class AtomicAbsoluteLinksTests : IClassFixture, OperationsDbContext>> { private readonly IntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicLinksTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicAbsoluteLinksTests(IntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; @@ -31,84 +29,10 @@ public AtomicLinksTests(IntegrationTestContext(); - options.Namespace = "api"; - options.UseRelativeLinks = true; - - 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/v1/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"); - } - [Fact] public async Task Update_resource_with_side_effects_returns_absolute_links() { // Arrange - var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); - options.Namespace = null; - options.UseRelativeLinks = false; - var existingLanguage = _fakers.TextLanguage.Generate(); var existingCompany = _fakers.RecordCompany.Generate(); @@ -149,7 +73,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs new file mode 100644 index 0000000000..21e3c3cc0d --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs @@ -0,0 +1,99 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Links +{ + public sealed class AtomicRelativeLinksWithNamespaceTests + : IClassFixture, OperationsDbContext>> + { + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicRelativeLinksWithNamespaceTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + + 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/Mixed/AtomicLocalIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs index 8966548704..946321b495 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs @@ -81,7 +81,7 @@ public async Task Can_create_resource_with_ToOne_relationship_using_local_ID() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -176,7 +176,7 @@ public async Task Can_create_resource_with_OneToMany_relationship_using_local_ID } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -270,7 +270,7 @@ public async Task Can_create_resource_with_ManyToMany_relationship_using_local_I } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -341,7 +341,7 @@ public async Task Cannot_consume_local_ID_that_is_assigned_in_same_operation() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -396,7 +396,7 @@ public async Task Cannot_reassign_local_ID() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -453,7 +453,7 @@ public async Task Can_update_resource_using_local_ID() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -572,7 +572,7 @@ public async Task Can_update_resource_with_relationships_using_local_ID() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -679,7 +679,7 @@ public async Task Can_create_ToOne_relationship_using_local_ID() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -779,7 +779,7 @@ public async Task Can_create_OneToMany_relationship_using_local_ID() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -879,7 +879,7 @@ public async Task Can_create_ManyToMany_relationship_using_local_ID() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1002,7 +1002,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1124,7 +1124,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1247,7 +1247,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1391,7 +1391,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1547,7 +1547,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1691,7 +1691,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1761,7 +1761,7 @@ public async Task Can_delete_resource_using_local_ID() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1809,7 +1809,7 @@ public async Task Cannot_consume_unassigned_local_ID_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1847,7 +1847,7 @@ public async Task Cannot_consume_unassigned_local_ID_in_data_element() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1899,7 +1899,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1944,7 +1944,7 @@ public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_elemen } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1992,7 +1992,7 @@ public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_array( } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -2040,7 +2040,7 @@ public async Task Cannot_consume_local_ID_of_different_type_in_same_operation() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -2086,7 +2086,7 @@ public async Task Cannot_consume_local_ID_of_different_type_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -2135,7 +2135,7 @@ public async Task Cannot_consume_local_ID_of_different_type_in_data_element() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -2198,7 +2198,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -2260,7 +2260,7 @@ public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -2319,7 +2319,7 @@ public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs index 19130b5f68..c3b7f90630 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs @@ -63,7 +63,7 @@ public async Task Cannot_process_more_operations_than_maximum() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -114,7 +114,7 @@ public async Task Can_process_operations_same_as_maximum() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -153,7 +153,7 @@ public async Task Can_process_high_number_of_operations_when_unconstrained() atomic__operations = operationElements }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs index 3b1c1380b1..53bb03730d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs @@ -31,7 +31,7 @@ public MixedOperationsTests(IntegrationTestContext(route, null); @@ -61,7 +61,7 @@ public async Task Cannot_process_for_broken_JSON_request_body() // Arrange var requestBody = "{\"atomic__operations\":[{\"op\":"; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -87,7 +87,7 @@ public async Task Cannot_process_empty_operations_array() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -133,7 +133,7 @@ public async Task Cannot_process_for_unknown_operation_code() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -216,7 +216,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationStartup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationStartup.cs deleted file mode 100644 index 6130e7fb3d..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationStartup.cs +++ /dev/null @@ -1,20 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using Microsoft.Extensions.Configuration; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ModelStateValidation -{ - public sealed class AtomicModelStateValidationStartup : TestableStartup - { - public AtomicModelStateValidationStartup(IConfiguration configuration) - : base(configuration) - { - } - - protected override void SetJsonApiOptions(JsonApiOptions options) - { - base.SetJsonApiOptions(options); - - options.ValidateModelState = true; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs index 62346d387a..4776449beb 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs @@ -11,12 +11,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ModelStateValidation { public sealed class AtomicModelStateValidationTests - : IClassFixture> + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext _testContext; + private readonly IntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicModelStateValidationTests(IntegrationTestContext testContext) + public AtomicModelStateValidationTests(IntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; @@ -50,7 +50,7 @@ public async Task Cannot_create_resource_with_multiple_violations() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -117,7 +117,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -174,7 +174,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -228,7 +228,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -291,7 +291,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -348,7 +348,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -407,7 +407,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -465,7 +465,7 @@ public async Task Validates_all_operations_before_execution_starts() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs index 0e71616210..04cbb0a5a8 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs @@ -62,7 +62,7 @@ public async Task Cannot_include_on_operations_endpoint() } }; - var route = "/api/v1/operations?include=recordCompanies"; + var route = "/operations?include=recordCompanies"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -99,7 +99,7 @@ public async Task Cannot_filter_on_operations_endpoint() } }; - var route = "/api/v1/operations?filter=equals(id,'1')"; + var route = "/operations?filter=equals(id,'1')"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -136,7 +136,7 @@ public async Task Cannot_sort_on_operations_endpoint() } }; - var route = "/api/v1/operations?sort=-id"; + var route = "/operations?sort=-id"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -173,7 +173,7 @@ public async Task Cannot_use_pagination_number_on_operations_endpoint() } }; - var route = "/api/v1/operations?page[number]=1"; + var route = "/operations?page[number]=1"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -210,7 +210,7 @@ public async Task Cannot_use_pagination_size_on_operations_endpoint() } }; - var route = "/api/v1/operations?page[size]=1"; + var route = "/operations?page[size]=1"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -247,7 +247,7 @@ public async Task Cannot_use_sparse_fieldset_on_operations_endpoint() } }; - var route = "/api/v1/operations?fields[recordCompanies]=id"; + var route = "/operations?fields[recordCompanies]=id"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -316,7 +316,7 @@ public async Task Cannot_use_Queryable_handler_on_operations_endpoint() } }; - var route = "/api/v1/operations?isRecentlyReleased=true"; + var route = "/operations?isRecentlyReleased=true"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -358,7 +358,7 @@ public async Task Can_use_defaults_on_operations_endpoint() } }; - var route = "/api/v1/operations?defaults=false"; + var route = "/operations?defaults=false"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -401,7 +401,7 @@ public async Task Can_use_nulls_on_operations_endpoint() } }; - var route = "/api/v1/operations?nulls=false"; + var route = "/operations?nulls=false"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/TransactionConsistencyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/TransactionConsistencyTests.cs index 6fdafae473..ff5b190d3a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/TransactionConsistencyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/TransactionConsistencyTests.cs @@ -59,7 +59,7 @@ public async Task Cannot_use_non_transactional_repository() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -96,7 +96,7 @@ public async Task Cannot_use_transactional_repository_without_active_transaction } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -133,7 +133,7 @@ public async Task Cannot_use_distributed_transaction() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs index 0ee28e1d67..26e941d05a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs @@ -64,7 +64,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -137,7 +137,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -225,7 +225,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -265,7 +265,7 @@ public async Task Cannot_add_for_href_element() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -300,7 +300,7 @@ public async Task Cannot_add_for_missing_type_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -336,7 +336,7 @@ public async Task Cannot_add_for_unknown_type_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -371,7 +371,7 @@ public async Task Cannot_add_for_missing_ID_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -423,7 +423,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -460,7 +460,7 @@ public async Task Cannot_add_for_ID_and_local_ID_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -495,7 +495,7 @@ public async Task Cannot_add_for_missing_relationship_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -531,7 +531,7 @@ public async Task Cannot_add_for_unknown_relationship_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -576,7 +576,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -619,7 +619,7 @@ public async Task Cannot_add_for_missing_type_in_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -663,7 +663,7 @@ public async Task Cannot_add_for_unknown_type_in_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -706,7 +706,7 @@ public async Task Cannot_add_for_missing_ID_in_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -751,7 +751,7 @@ public async Task Cannot_add_for_ID_and_local_ID_in_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -809,7 +809,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -867,7 +867,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -913,7 +913,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs index 16bf84d2aa..dd2370d97d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs @@ -64,7 +64,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -135,7 +135,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -230,7 +230,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -271,7 +271,7 @@ public async Task Cannot_remove_for_href_element() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -306,7 +306,7 @@ public async Task Cannot_remove_for_missing_type_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -342,7 +342,7 @@ public async Task Cannot_remove_for_unknown_type_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -377,7 +377,7 @@ public async Task Cannot_remove_for_missing_ID_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -429,7 +429,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -466,7 +466,7 @@ public async Task Cannot_remove_for_ID_and_local_ID_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -502,7 +502,7 @@ public async Task Cannot_remove_for_unknown_relationship_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -547,7 +547,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -590,7 +590,7 @@ public async Task Cannot_remove_for_missing_type_in_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -634,7 +634,7 @@ public async Task Cannot_remove_for_unknown_type_in_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -677,7 +677,7 @@ public async Task Cannot_remove_for_missing_ID_in_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -722,7 +722,7 @@ public async Task Cannot_remove_for_ID_and_local_ID_in_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -780,7 +780,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -838,7 +838,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -885,7 +885,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs index 84b97e1b43..979f49f74a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs @@ -61,7 +61,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -126,7 +126,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -197,7 +197,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -275,7 +275,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -317,7 +317,7 @@ public async Task Cannot_replace_for_href_element() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -352,7 +352,7 @@ public async Task Cannot_replace_for_missing_type_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -388,7 +388,7 @@ public async Task Cannot_replace_for_unknown_type_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -423,7 +423,7 @@ public async Task Cannot_replace_for_missing_ID_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -475,7 +475,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -512,7 +512,7 @@ public async Task Cannot_replace_for_ID_and_local_ID_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -548,7 +548,7 @@ public async Task Cannot_replace_for_unknown_relationship_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -593,7 +593,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -636,7 +636,7 @@ public async Task Cannot_replace_for_missing_type_in_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -680,7 +680,7 @@ public async Task Cannot_replace_for_unknown_type_in_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -723,7 +723,7 @@ public async Task Cannot_replace_for_missing_ID_in_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -768,7 +768,7 @@ public async Task Cannot_replace_for_ID_and_local_ID_in_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -826,7 +826,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -884,7 +884,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs index 479c7fb00a..890d68dc10 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs @@ -60,7 +60,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -115,7 +115,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -170,7 +170,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -228,7 +228,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -283,7 +283,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -338,7 +338,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -396,7 +396,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -457,7 +457,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -518,7 +518,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -557,7 +557,7 @@ public async Task Cannot_create_for_href_element() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -592,7 +592,7 @@ public async Task Cannot_create_for_missing_type_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -628,7 +628,7 @@ public async Task Cannot_create_for_unknown_type_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -663,7 +663,7 @@ public async Task Cannot_create_for_missing_ID_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -714,7 +714,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -751,7 +751,7 @@ public async Task Cannot_create_for_ID_and_local_ID_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -787,7 +787,7 @@ public async Task Cannot_create_for_unknown_relationship_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -839,7 +839,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -879,7 +879,7 @@ public async Task Cannot_create_for_missing_type_in_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -920,7 +920,7 @@ public async Task Cannot_create_for_unknown_type_in_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -960,7 +960,7 @@ public async Task Cannot_create_for_missing_ID_in_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1002,7 +1002,7 @@ public async Task Cannot_create_for_ID_and_local_ID_in_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1051,7 +1051,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1100,7 +1100,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs index 8acfe7eb93..2b72d3b1e1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs @@ -66,7 +66,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -136,7 +136,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -212,7 +212,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -295,7 +295,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -356,7 +356,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -404,7 +404,7 @@ public async Task Cannot_replace_for_missing_type_in_relationship_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -453,7 +453,7 @@ public async Task Cannot_replace_for_unknown_type_in_relationship_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -501,7 +501,7 @@ public async Task Cannot_replace_for_missing_ID_in_relationship_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -551,7 +551,7 @@ public async Task Cannot_replace_for_ID_and_local_ID_relationship_in_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -614,7 +614,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -677,7 +677,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs index dc3d40b31c..c107b8ed0b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs @@ -69,7 +69,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => atomic__operations = operationElements }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -131,7 +131,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -189,7 +189,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -247,7 +247,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -293,7 +293,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -360,7 +360,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -419,7 +419,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -472,7 +472,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -502,7 +502,7 @@ public async Task Cannot_update_resource_for_href_element() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -555,7 +555,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -605,7 +605,7 @@ public async Task Cannot_update_resource_for_missing_type_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -650,7 +650,7 @@ public async Task Cannot_update_resource_for_missing_ID_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -697,7 +697,7 @@ public async Task Cannot_update_resource_for_ID_and_local_ID_in_ref() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -737,7 +737,7 @@ public async Task Cannot_update_resource_for_missing_type_in_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -777,7 +777,7 @@ public async Task Cannot_update_resource_for_missing_ID_in_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -819,7 +819,7 @@ public async Task Cannot_update_resource_for_ID_and_local_ID_in_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -869,7 +869,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -915,7 +915,7 @@ public async Task Cannot_update_on_resource_type_mismatch_between_ref_and_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -961,7 +961,7 @@ public async Task Cannot_update_on_resource_ID_mismatch_between_ref_and_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1007,7 +1007,7 @@ public async Task Cannot_update_on_resource_local_ID_mismatch_between_ref_and_da } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1053,7 +1053,7 @@ public async Task Cannot_update_on_mixture_of_ID_and_local_ID_between_ref_and_da } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1099,7 +1099,7 @@ public async Task Cannot_update_on_mixture_of_local_ID_and_ID_between_ref_and_da } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1140,7 +1140,7 @@ public async Task Cannot_update_resource_for_unknown_type() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1181,7 +1181,7 @@ public async Task Cannot_update_resource_for_unknown_ID() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1228,7 +1228,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1275,7 +1275,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1322,7 +1322,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1369,7 +1369,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -1455,7 +1455,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs index e489e38862..2c48c6b280 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs @@ -65,7 +65,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -125,7 +125,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -185,7 +185,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -248,7 +248,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -308,7 +308,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -368,7 +368,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -431,7 +431,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -497,7 +497,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -563,7 +563,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -628,7 +628,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -673,7 +673,7 @@ public async Task Cannot_create_for_missing_type_in_relationship_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -719,7 +719,7 @@ public async Task Cannot_create_for_unknown_type_in_relationship_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -764,7 +764,7 @@ public async Task Cannot_create_for_missing_ID_in_relationship_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -811,7 +811,7 @@ public async Task Cannot_create_for_ID_and_local_ID_in_relationship_data() } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -865,7 +865,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -919,7 +919,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs index ce8d427b44..fa848dc730 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs @@ -65,7 +65,7 @@ public async Task Permits_no_Accept_headers_at_operations_endpoint() } }; - var route = "/api/v1/operations"; + var route = "/operations"; var contentType = HeaderConstants.AtomicOperationsMediaType; var acceptHeaders = new MediaTypeWithQualityHeaderValue[0]; @@ -160,7 +160,7 @@ public async Task Permits_JsonApi_with_AtomicOperations_extension_in_Accept_head } }; - var route = "/api/v1/operations"; + var route = "/operations"; var contentType = HeaderConstants.AtomicOperationsMediaType; var acceptHeaders = new[] @@ -229,7 +229,7 @@ public async Task Denies_JsonApi_in_Accept_headers_at_operations_endpoint() } }; - var route = "/api/v1/operations"; + var route = "/operations"; var contentType = HeaderConstants.AtomicOperationsMediaType; var acceptHeaders = new[] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs index a15b9e060a..aec0f8a6b2 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs @@ -63,7 +63,7 @@ public async Task Returns_JsonApi_ContentType_header_with_AtomicOperations_exten } }; - var route = "/api/v1/operations"; + var route = "/operations"; // Act var (httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); @@ -153,7 +153,7 @@ public async Task Permits_JsonApi_ContentType_header_with_AtomicOperations_exten } }; - var route = "/api/v1/operations"; + var route = "/operations"; var contentType = HeaderConstants.AtomicOperationsMediaType; // Act @@ -341,7 +341,7 @@ public async Task Denies_JsonApi_ContentType_header_at_operations_endpoint() } }; - var route = "/api/v1/operations"; + var route = "/operations"; var contentType = HeaderConstants.MediaType; // Act diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationStartup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidationStartup.cs similarity index 83% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationStartup.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidationStartup.cs index 71c15c3234..a3918a6639 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationStartup.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidationStartup.cs @@ -2,12 +2,13 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +namespace JsonApiDotNetCoreExampleTests.IntegrationTests { public sealed class ModelStateValidationStartup : TestableStartup where TDbContext : DbContext { - public ModelStateValidationStartup(IConfiguration configuration) : base(configuration) + public ModelStateValidationStartup(IConfiguration configuration) + : base(configuration) { } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RelativeApiNamespaceStartup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RelativeApiNamespaceStartup.cs new file mode 100644 index 0000000000..50d987c58f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RelativeApiNamespaceStartup.cs @@ -0,0 +1,23 @@ +using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests +{ + public sealed class RelativeApiNamespaceStartup : TestableStartup + where TDbContext : DbContext + { + public RelativeApiNamespaceStartup(IConfiguration configuration) + : base(configuration) + { + } + + protected override void SetJsonApiOptions(JsonApiOptions options) + { + base.SetJsonApiOptions(options); + + options.Namespace = "api"; + options.UseRelativeLinks = true; + } + } +} From e6baf42122c11ca1494691ef621631901092ebe9 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 18 Jan 2021 17:59:25 +0100 Subject: [PATCH 057/123] Fixed: top-level meta in atomic:operation responses --- .../AtomicOperationsResponseSerializer.cs | 6 +- .../Objects/AtomicOperationCode.cs | 2 +- .../Objects/AtomicOperationObject.cs | 2 +- .../Objects/AtomicOperationsDocument.cs | 26 ++- .../Serialization/Objects/AtomicReference.cs | 3 + .../Objects/AtomicResultObject.cs | 2 +- .../Meta/AtomicResponseMetaTests.cs | 148 ++++++++++++++++++ 7 files changed, 184 insertions(+), 5 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs diff --git a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs index babc41a70b..19a9ccc9a7 100644 --- a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs @@ -18,16 +18,18 @@ public sealed class AtomicOperationsResponseSerializer : BaseSerializer, IJsonAp private readonly ILinkBuilder _linkBuilder; private readonly IResourceContextProvider _resourceContextProvider; private readonly IJsonApiOptions _options; + private readonly IMetaBuilder _metaBuilder; public string ContentType { get; } = HeaderConstants.AtomicOperationsMediaType; public AtomicOperationsResponseSerializer(IResourceObjectBuilder resourceObjectBuilder, ILinkBuilder linkBuilder, - IResourceContextProvider resourceContextProvider, IJsonApiOptions options) + IResourceContextProvider resourceContextProvider, IJsonApiOptions options, IMetaBuilder metaBuilder) : base(resourceObjectBuilder) { _linkBuilder = linkBuilder ?? throw new ArgumentNullException(nameof(linkBuilder)); _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); _options = options ?? throw new ArgumentNullException(nameof(options)); + _metaBuilder = metaBuilder ?? throw new ArgumentNullException(nameof(metaBuilder)); } public string Serialize(object content) @@ -81,6 +83,8 @@ private string SerializeOperationsDocument(IEnumerable resources) }); } + document.Meta = _metaBuilder.Build(); + return SerializeObject(document, _options.SerializerSettings); } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationCode.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationCode.cs index cf47e3774d..f7852773b3 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationCode.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationCode.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.Serialization.Objects { /// - /// See https://jsonapi.org/ext/atomic/#operation-objects + /// See https://jsonapi.org/ext/atomic/#operation-objects. /// [JsonConverter(typeof(StringEnumConverter))] public enum AtomicOperationCode diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs index 45dfede0c1..df26bf7a47 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs @@ -6,7 +6,7 @@ namespace JsonApiDotNetCore.Serialization.Objects { /// - /// https://jsonapi.org/ext/atomic/#operation-objects + /// See https://jsonapi.org/ext/atomic/#operation-objects. /// public sealed class AtomicOperationObject : ExposableData { diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationsDocument.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationsDocument.cs index 6b66075bfe..8d08f5fb31 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationsDocument.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationsDocument.cs @@ -4,13 +4,37 @@ namespace JsonApiDotNetCore.Serialization.Objects { /// - /// https://jsonapi.org/ext/atomic/#document-structure + /// 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 index 0133f409f6..5847d33fe9 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs @@ -3,6 +3,9 @@ namespace JsonApiDotNetCore.Serialization.Objects { + /// + /// See 'ref' in https://jsonapi.org/ext/atomic/#operation-objects. + /// public sealed class AtomicReference : ResourceIdentifierObject { [JsonProperty("relationship", NullValueHandling = NullValueHandling.Ignore)] diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs index 168ce9ff66..44b5f691d7 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.Serialization.Objects { /// - /// https://jsonapi.org/ext/atomic/#result-objects + /// See https://jsonapi.org/ext/atomic/#result-objects. /// public sealed class AtomicResultObject : ExposableData { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs new file mode 100644 index 0000000000..e0b70ecb5f --- /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 JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Meta +{ + public sealed class AtomicResponseMetaTests : IClassFixture, OperationsDbContext>> + { + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicResponseMetaTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + + 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" + } + }; + } + } +} From 1d6065bb68a40a6c2264e0bd6ae1d1d92bceb1bb Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 19 Jan 2021 14:24:44 +0100 Subject: [PATCH 058/123] Fixed: resource-level meta in atomic:operation responses --- .../AtomicOperationsResponseSerializer.cs | 8 +- .../Meta/AtomicResourceMetaTests.cs | 136 ++++++++++++++++++ .../Meta/MusicTrackDefinition.cs | 22 +++ .../Meta/TextLanguageDefinition.cs | 22 +++ 4 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/MusicTrackDefinition.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/TextLanguageDefinition.cs diff --git a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs index 19a9ccc9a7..de7afccaf1 100644 --- a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs @@ -19,17 +19,20 @@ public sealed class AtomicOperationsResponseSerializer : BaseSerializer, IJsonAp private readonly IResourceContextProvider _resourceContextProvider; private readonly IJsonApiOptions _options; private readonly IMetaBuilder _metaBuilder; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; public string ContentType { get; } = HeaderConstants.AtomicOperationsMediaType; - public AtomicOperationsResponseSerializer(IResourceObjectBuilder resourceObjectBuilder, ILinkBuilder linkBuilder, - IResourceContextProvider resourceContextProvider, IJsonApiOptions options, IMetaBuilder metaBuilder) + public AtomicOperationsResponseSerializer(IMetaBuilder metaBuilder, ILinkBuilder linkBuilder, + IResourceObjectBuilder resourceObjectBuilder, IResourceContextProvider resourceContextProvider, + IResourceDefinitionAccessor resourceDefinitionAccessor, IJsonApiOptions options) : base(resourceObjectBuilder) { _linkBuilder = linkBuilder ?? throw new ArgumentNullException(nameof(linkBuilder)); _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); _options = options ?? throw new ArgumentNullException(nameof(options)); _metaBuilder = metaBuilder ?? throw new ArgumentNullException(nameof(metaBuilder)); + _resourceDefinitionAccessor = resourceDefinitionAccessor ?? throw new ArgumentNullException(nameof(resourceDefinitionAccessor)); } public string Serialize(object content) @@ -74,6 +77,7 @@ private string SerializeOperationsDocument(IEnumerable resources) if (resourceObject != null) { resourceObject.Links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); + resourceObject.Meta = _resourceDefinitionAccessor.GetMeta(resource.GetType(), resource); } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs new file mode 100644 index 0000000000..003c818e27 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs @@ -0,0 +1,136 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Extensions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Meta +{ + public sealed class AtomicResourceMetaTests + : IClassFixture, OperationsDbContext>> + { + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicResourceMetaTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + + services.AddScoped, MusicTrackDefinition>(); + services.AddScoped, TextLanguageDefinition>(); + }); + } + + [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/MusicTrackDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/MusicTrackDefinition.cs new file mode 100644 index 0000000000..e3cd371ccc --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/MusicTrackDefinition.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 MusicTrackDefinition : JsonApiResourceDefinition + { + public MusicTrackDefinition(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/TextLanguageDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/TextLanguageDefinition.cs new file mode 100644 index 0000000000..7a0e03d25f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/TextLanguageDefinition.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 TextLanguageDefinition : JsonApiResourceDefinition + { + public TextLanguageDefinition(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." + }; + } + } +} From 79af8f1949ccd03c86c3035b0be8d7332d498a2e Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 19 Jan 2021 15:45:44 +0100 Subject: [PATCH 059/123] Renames --- .../AtomicOperations/Meta/AtomicResourceMetaTests.cs | 4 ++-- ...{MusicTrackDefinition.cs => MusicTrackMetaDefinition.cs} | 4 ++-- ...tLanguageDefinition.cs => TextLanguageMetaDefinition.cs} | 4 ++-- .../AtomicOperations/QueryStrings/AtomicQueryStringTests.cs | 2 +- .../MusicTrackReleaseDefinition.cs} | 6 +++--- 5 files changed, 10 insertions(+), 10 deletions(-) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/{MusicTrackDefinition.cs => MusicTrackMetaDefinition.cs} (73%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/{TextLanguageDefinition.cs => TextLanguageMetaDefinition.cs} (73%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/{MusicTrackResourceDefinition.cs => QueryStrings/MusicTrackReleaseDefinition.cs} (84%) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs index 003c818e27..c3887b077a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs @@ -27,8 +27,8 @@ public AtomicResourceMetaTests(IntegrationTestContext apm.ApplicationParts.Add(part)); - services.AddScoped, MusicTrackDefinition>(); - services.AddScoped, TextLanguageDefinition>(); + services.AddScoped, MusicTrackMetaDefinition>(); + services.AddScoped, TextLanguageMetaDefinition>(); }); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/MusicTrackDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs similarity index 73% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/MusicTrackDefinition.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs index e3cd371ccc..2ff54786a0 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/MusicTrackDefinition.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs @@ -5,9 +5,9 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Meta { - public sealed class MusicTrackDefinition : JsonApiResourceDefinition + public sealed class MusicTrackMetaDefinition : JsonApiResourceDefinition { - public MusicTrackDefinition(IResourceGraph resourceGraph) : base(resourceGraph) + public MusicTrackMetaDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/TextLanguageDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs similarity index 73% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/TextLanguageDefinition.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs index 7a0e03d25f..e0fd3f9f78 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/TextLanguageDefinition.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs @@ -5,9 +5,9 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Meta { - public sealed class TextLanguageDefinition : JsonApiResourceDefinition + public sealed class TextLanguageMetaDefinition : JsonApiResourceDefinition { - public TextLanguageDefinition(IResourceGraph resourceGraph) : base(resourceGraph) + public TextLanguageMetaDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs index 04cbb0a5a8..5b2ed8cf0a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs @@ -32,7 +32,7 @@ public AtomicQueryStringTests(IntegrationTestContext apm.ApplicationParts.Add(part)); services.AddSingleton(new FrozenSystemClock(_frozenTime)); - services.AddScoped, MusicTrackResourceDefinition>(); + services.AddScoped, MusicTrackReleaseDefinition>(); }); var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrackResourceDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs similarity index 84% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrackResourceDefinition.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs index 2606d29b58..439cd821b5 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrackResourceDefinition.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs @@ -5,13 +5,13 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.QueryStrings { - public sealed class MusicTrackResourceDefinition : JsonApiResourceDefinition + public sealed class MusicTrackReleaseDefinition : JsonApiResourceDefinition { private readonly ISystemClock _systemClock; - public MusicTrackResourceDefinition(IResourceGraph resourceGraph, ISystemClock systemClock) + public MusicTrackReleaseDefinition(IResourceGraph resourceGraph, ISystemClock systemClock) : base(resourceGraph) { _systemClock = systemClock ?? throw new ArgumentNullException(nameof(systemClock)); From bb7c7c7283f9c7b7d8f6ec1c188b5b15fb487270 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 19 Jan 2021 16:03:06 +0100 Subject: [PATCH 060/123] Fixed: duplicate calls to ResourceDefinition.GetMeta --- .../Serialization/AtomicOperationsResponseSerializer.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs index de7afccaf1..ee88621948 100644 --- a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs @@ -19,20 +19,18 @@ public sealed class AtomicOperationsResponseSerializer : BaseSerializer, IJsonAp private readonly IResourceContextProvider _resourceContextProvider; private readonly IJsonApiOptions _options; private readonly IMetaBuilder _metaBuilder; - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; public string ContentType { get; } = HeaderConstants.AtomicOperationsMediaType; public AtomicOperationsResponseSerializer(IMetaBuilder metaBuilder, ILinkBuilder linkBuilder, IResourceObjectBuilder resourceObjectBuilder, IResourceContextProvider resourceContextProvider, - IResourceDefinitionAccessor resourceDefinitionAccessor, IJsonApiOptions options) + IJsonApiOptions options) : base(resourceObjectBuilder) { _linkBuilder = linkBuilder ?? throw new ArgumentNullException(nameof(linkBuilder)); _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); _options = options ?? throw new ArgumentNullException(nameof(options)); _metaBuilder = metaBuilder ?? throw new ArgumentNullException(nameof(metaBuilder)); - _resourceDefinitionAccessor = resourceDefinitionAccessor ?? throw new ArgumentNullException(nameof(resourceDefinitionAccessor)); } public string Serialize(object content) @@ -77,7 +75,6 @@ private string SerializeOperationsDocument(IEnumerable resources) if (resourceObject != null) { resourceObject.Links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); - resourceObject.Meta = _resourceDefinitionAccessor.GetMeta(resource.GetType(), resource); } } From 24a4ccc8ecc88834caffac8fa9d4403180549653 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 19 Jan 2021 17:10:18 +0100 Subject: [PATCH 061/123] Fixes resource definition callbacks for sparse fieldsets --- .../AtomicOperationsProcessor.cs | 6 +- .../IAtomicOperationsProcessor.cs | 2 +- .../Processors/AddToRelationshipProcessor.cs | 2 +- .../Processors/CreateProcessor.cs | 4 +- .../Processors/DeleteProcessor.cs | 2 +- .../Processors/IAtomicOperationProcessor.cs | 2 +- .../RemoveFromRelationshipProcessor.cs | 2 +- .../Processors/SetRelationshipProcessor.cs | 2 +- .../Processors/UpdateProcessor.cs | 6 +- .../Middleware/JsonApiMiddleware.cs | 4 +- .../Middleware/OperationKindExtensions.cs | 9 +- .../Queries/Internal/SparseFieldSetCache.cs | 5 + .../Resources/OperationContainer.cs | 7 + .../AtomicOperationsResponseSerializer.cs | 30 ++-- .../Serialization/FieldsToSerialize.cs | 6 + .../Serialization/IFieldsToSerialize.cs | 5 + .../NeverSameResourceChangeTracker.cs | 5 +- .../LyricPermissionProvider.cs | 8 + .../LyricTextDefinition.cs | 26 +++ .../SparseFieldSetResourceDefinitionTests.cs | 162 ++++++++++++++++++ 20 files changed, 260 insertions(+), 35 deletions(-) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/{Links => }/NeverSameResourceChangeTracker.cs (82%) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/LyricPermissionProvider.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/LyricTextDefinition.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSetResourceDefinitionTests.cs diff --git a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs index 1aa07f89c9..b915e89d52 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs @@ -34,13 +34,13 @@ public AtomicOperationsProcessor(IAtomicOperationProcessorResolver resolver, } /// - public async Task> ProcessAsync(IList operations, CancellationToken cancellationToken) + public async Task> ProcessAsync(IList operations, CancellationToken cancellationToken) { if (operations == null) throw new ArgumentNullException(nameof(operations)); // TODO: @OPS: Consider to validate local:id usage upfront. - var results = new List(); + var results = new List(); await using var transaction = await _atomicOperationsTransactionFactory.BeginTransactionAsync(cancellationToken); try @@ -86,7 +86,7 @@ public async Task> ProcessAsync(IList o } } - private async Task ProcessOperation(OperationContainer operation, CancellationToken cancellationToken) + private async Task ProcessOperation(OperationContainer operation, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsProcessor.cs index 9770fb14d6..9429cad3ea 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsProcessor.cs @@ -13,6 +13,6 @@ public interface IAtomicOperationsProcessor /// /// Processes the list of specified operations. /// - Task> ProcessAsync(IList operations, CancellationToken cancellationToken); + Task> ProcessAsync(IList operations, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs index 31f810298f..1bcfd7edf7 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs @@ -23,7 +23,7 @@ public AddToRelationshipProcessor(IAddToRelationshipService serv } /// - public async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { if (operation == null) throw new ArgumentNullException(nameof(operation)); diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs index 6d1843804e..3ce4697944 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs @@ -28,7 +28,7 @@ public CreateProcessor(ICreateService service, ILocalIdTracker l } /// - public async Task ProcessAsync(OperationContainer operation, + public async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { if (operation == null) throw new ArgumentNullException(nameof(operation)); @@ -43,7 +43,7 @@ public async Task ProcessAsync(OperationContainer operation, _localIdTracker.Assign(operation.Resource.LocalId, resourceContext.PublicName, serverId); } - return newResource; + return newResource == null ? null : operation.WithResource(newResource); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs index ff3288ffb0..b692e334ea 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs @@ -18,7 +18,7 @@ public DeleteProcessor(IDeleteService service) } /// - public async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { if (operation == null) throw new ArgumentNullException(nameof(operation)); diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IAtomicOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAtomicOperationProcessor.cs index a838254c78..eea1dea860 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IAtomicOperationProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAtomicOperationProcessor.cs @@ -9,6 +9,6 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors /// public interface IAtomicOperationProcessor { - Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); + Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs index d0dcc1d110..c013bcb07f 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs @@ -23,7 +23,7 @@ public RemoveFromRelationshipProcessor(IRemoveFromRelationshipService - public async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { if (operation == null) throw new ArgumentNullException(nameof(operation)); diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs index d08c310690..38f5a06f0b 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs @@ -23,7 +23,7 @@ public SetRelationshipProcessor(ISetRelationshipService service) } /// - public async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { if (operation == null) throw new ArgumentNullException(nameof(operation)); diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs index dd2da50f11..583d9052e2 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs @@ -23,12 +23,14 @@ public UpdateProcessor(IUpdateService service) } /// - public async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { if (operation == null) throw new ArgumentNullException(nameof(operation)); var resource = (TResource) operation.Resource; - return await _service.UpdateAsync(resource.Id, resource, cancellationToken); + var updated = await _service.UpdateAsync(resource.Id, resource, cancellationToken); + + return updated == null ? null : operation.WithResource(updated); } } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index 7431253e0f..23d4ab05e0 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -56,7 +56,7 @@ public async Task Invoke(HttpContext httpContext, return; } - SetupRequest((JsonApiRequest)request, primaryResourceContext, routeValues, options, resourceContextProvider, httpContext.Request); + SetupResourceRequest((JsonApiRequest)request, primaryResourceContext, routeValues, options, resourceContextProvider, httpContext.Request); httpContext.RegisterJsonApiRequest(); } @@ -175,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) { diff --git a/src/JsonApiDotNetCore/Middleware/OperationKindExtensions.cs b/src/JsonApiDotNetCore/Middleware/OperationKindExtensions.cs index f21e8711fb..23e582eb73 100644 --- a/src/JsonApiDotNetCore/Middleware/OperationKindExtensions.cs +++ b/src/JsonApiDotNetCore/Middleware/OperationKindExtensions.cs @@ -4,14 +4,13 @@ public static class OperationKindExtensions { public static bool IsRelationship(this OperationKind kind) { - return kind == OperationKind.SetRelationship || kind == OperationKind.AddToRelationship || - kind == OperationKind.RemoveFromRelationship; + return IsRelationship((OperationKind?)kind); } - public static bool IsResource(this OperationKind kind) + public static bool IsRelationship(this OperationKind? kind) { - return kind == OperationKind.CreateResource || kind == OperationKind.UpdateResource || - kind == OperationKind.DeleteResource; + return kind == OperationKind.SetRelationship || kind == OperationKind.AddToRelationship || + kind == OperationKind.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/Resources/OperationContainer.cs b/src/JsonApiDotNetCore/Resources/OperationContainer.cs index 4f6bd74f6e..6ea3ac5c82 100644 --- a/src/JsonApiDotNetCore/Resources/OperationContainer.cs +++ b/src/JsonApiDotNetCore/Resources/OperationContainer.cs @@ -26,5 +26,12 @@ 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); + } } } diff --git a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs index ee88621948..02336aed69 100644 --- a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs @@ -17,6 +17,8 @@ public sealed class AtomicOperationsResponseSerializer : BaseSerializer, IJsonAp { private readonly ILinkBuilder _linkBuilder; private readonly IResourceContextProvider _resourceContextProvider; + private readonly IFieldsToSerialize _fieldsToSerialize; + private readonly IJsonApiRequest _request; private readonly IJsonApiOptions _options; private readonly IMetaBuilder _metaBuilder; @@ -24,20 +26,23 @@ public sealed class AtomicOperationsResponseSerializer : BaseSerializer, IJsonAp public AtomicOperationsResponseSerializer(IMetaBuilder metaBuilder, ILinkBuilder linkBuilder, IResourceObjectBuilder resourceObjectBuilder, IResourceContextProvider resourceContextProvider, + IFieldsToSerialize fieldsToSerialize, IJsonApiRequest request, IJsonApiOptions options) : base(resourceObjectBuilder) { _linkBuilder = linkBuilder ?? throw new ArgumentNullException(nameof(linkBuilder)); _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); + _fieldsToSerialize = fieldsToSerialize ?? throw new ArgumentNullException(nameof(fieldsToSerialize)); + _request = request ?? throw new ArgumentNullException(nameof(request)); _options = options ?? throw new ArgumentNullException(nameof(options)); _metaBuilder = metaBuilder ?? throw new ArgumentNullException(nameof(metaBuilder)); } public string Serialize(object content) { - if (content is IList resources) + if (content is IList operations) { - return SerializeOperationsDocument(resources); + return SerializeOperationsDocument(operations); } if (content is ErrorDocument errorDocument) @@ -48,30 +53,27 @@ public string Serialize(object content) throw new InvalidOperationException("Data being returned must be errors or an atomic:operations document."); } - private string SerializeOperationsDocument(IEnumerable resources) + private string SerializeOperationsDocument(IEnumerable operations) { var document = new AtomicOperationsDocument { Results = new List() }; - foreach (IIdentifiable resource in resources) + foreach (var operation in operations) { ResourceObject resourceObject = null; - if (resource != null) + if (operation != null) { - var resourceContext = _resourceContextProvider.GetResourceContext(resource.GetType()); + _request.CopyFrom(operation.Request); + _fieldsToSerialize.ResetCache(); - // TODO: @OPS: Should inject IFieldsToSerialize, which uses SparseFieldSetCache to call into resource definitions to hide fields. - // But then we need to update IJsonApiRequest for each loop entry, which we don't have access to anymore. - // That would be more correct, because ILinkBuilder depends on IJsonApiRequest too. + var resourceType = operation.Resource.GetType(); + var attributes = _fieldsToSerialize.GetAttributes(resourceType); + var relationships = _fieldsToSerialize.GetRelationships(resourceType); - var attributes = resourceContext.Attributes - .Where(attr => attr.Capabilities.HasFlag(AttrCapabilities.AllowView)) - .ToArray(); - - resourceObject = ResourceObjectBuilder.Build(resource, attributes, resourceContext.Relationships); + resourceObject = ResourceObjectBuilder.Build(operation.Resource, attributes, relationships); if (resourceObject != null) { resourceObject.Links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); 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/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/NeverSameResourceChangeTracker.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/NeverSameResourceChangeTracker.cs similarity index 82% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/NeverSameResourceChangeTracker.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/NeverSameResourceChangeTracker.cs index 34835057ad..236dc31923 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/NeverSameResourceChangeTracker.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/NeverSameResourceChangeTracker.cs @@ -1,7 +1,10 @@ using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Links +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations { + /// + /// Ensures the resource attributes are returned when creating/updating a resource. + /// internal sealed class NeverSameResourceChangeTracker : IResourceChangeTracker where TResource : class, IIdentifiable { 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/ResourceDefinitions/SparseFieldSetResourceDefinitionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSetResourceDefinitionTests.cs new file mode 100644 index 0000000000..23bf62cc90 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSetResourceDefinitionTests.cs @@ -0,0 +1,162 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions +{ + public sealed class SparseFieldSetResourceDefinitionTests + : IClassFixture, OperationsDbContext>> + { + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public SparseFieldSetResourceDefinitionTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + + 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); + } + } +} From 25ba72407105694fd0cb899cd1db5e648bb4cc51 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 20 Jan 2021 13:56:24 +0100 Subject: [PATCH 062/123] Fixed broken build after rebase master --- .../ResourceRepositoryAccessor.cs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs index ee7a6a2dc9..8269b459d5 100644 --- a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs @@ -55,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); } @@ -63,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); } @@ -71,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); } @@ -79,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); } @@ -87,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); } @@ -95,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); } @@ -103,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); } @@ -111,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); } @@ -134,7 +134,7 @@ protected virtual object ResolveReadRepository(Type resourceType) return _serviceProvider.GetRequiredService(resourceDefinitionType); } - protected virtual object ResolveWriteRepository(Type resourceType) + private object GetWriteRepository(Type resourceType) { var writeRepository = ResolveWriteRepository(resourceType); @@ -156,7 +156,7 @@ protected virtual object ResolveWriteRepository(Type resourceType) return writeRepository; } - private object ResolveWriteRepository(Type resourceType) + protected virtual object ResolveWriteRepository(Type resourceType) { var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); From ca14023e15ea6b2e3be326d4df6361d349c6611c Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 20 Jan 2021 14:48:29 +0100 Subject: [PATCH 063/123] Reduce diff with master branch --- benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs | 2 +- ...xception.cs => MultipleActiveTransactionsException.cs} | 4 ++-- .../Middleware/JsonApiRoutingConvention.cs | 2 +- .../Repositories/ResourceRepositoryAccessor.cs | 3 +-- src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs | 4 +--- .../Serialization/IJsonApiDeserializer.cs | 2 +- src/JsonApiDotNetCore/Serialization/JsonApiReader.cs | 2 +- .../Serialization/RequestDeserializer.cs | 6 +++--- .../ReadWrite/Updating/Resources/UpdateResourceTests.cs | 1 - test/UnitTests/Models/ResourceConstructionTests.cs | 8 ++++---- .../Serialization/Server/RequestDeserializerTests.cs | 6 +++--- 11 files changed, 18 insertions(+), 22 deletions(-) rename src/JsonApiDotNetCore/Errors/{MultipleTransactionsException.cs => MultipleActiveTransactionsException.cs} (83%) diff --git a/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs b/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs index f438d424af..5de48d8604 100644 --- a/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs +++ b/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs @@ -43,6 +43,6 @@ public JsonApiDeserializerBenchmarks() } [Benchmark] - public object DeserializeSimpleObject() => _jsonApiDeserializer.DeserializeDocument(Content); + public object DeserializeSimpleObject() => _jsonApiDeserializer.Deserialize(Content); } } diff --git a/src/JsonApiDotNetCore/Errors/MultipleTransactionsException.cs b/src/JsonApiDotNetCore/Errors/MultipleActiveTransactionsException.cs similarity index 83% rename from src/JsonApiDotNetCore/Errors/MultipleTransactionsException.cs rename to src/JsonApiDotNetCore/Errors/MultipleActiveTransactionsException.cs index 094ebc167d..cc2deb9f01 100644 --- a/src/JsonApiDotNetCore/Errors/MultipleTransactionsException.cs +++ b/src/JsonApiDotNetCore/Errors/MultipleActiveTransactionsException.cs @@ -7,9 +7,9 @@ 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 MultipleTransactionsException : JsonApiException + public sealed class MultipleActiveTransactionsException : JsonApiException { - public MultipleTransactionsException() + public MultipleActiveTransactionsException() : base(new Error(HttpStatusCode.UnprocessableEntity) { Title = "Unsupported combination of resource types in atomic:operations request.", diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs index 4bc4df995b..dfe78fce07 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs @@ -78,7 +78,7 @@ public void Apply(ApplicationModel application) } } } - + if (!RoutingConventionDisabled(controller)) { continue; diff --git a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs index 8269b459d5..cf14cfb704 100644 --- a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs @@ -144,12 +144,11 @@ private object GetWriteRepository(Type resourceType) { var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); throw new MissingTransactionSupportException(resourceContext.PublicName); - } if (repository.TransactionId != _request.TransactionId) { - throw new MultipleTransactionsException(); + throw new MultipleActiveTransactionsException(); } } diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs index c50d412c9d..0b9626ffc1 100644 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs @@ -54,9 +54,7 @@ protected object DeserializeBody(string body) { if (Document.IsManyData) { - return Document.ManyData - .Select(ParseResourceObject) - .ToHashSet(IdentifiableComparer.Instance); + return Document.ManyData.Select(ParseResourceObject).ToHashSet(IdentifiableComparer.Instance); } if (Document.SingleData != null) diff --git a/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs index 406ec149b7..b8dea76109 100644 --- a/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs @@ -12,6 +12,6 @@ public interface IJsonApiDeserializer /// from . /// /// The JSON to be deserialized. - object DeserializeDocument(string body); + object Deserialize(string body); } } diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index 2c3705b596..7413933f85 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs @@ -55,7 +55,7 @@ public async Task ReadAsync(InputFormatterContext context) { try { - model = _deserializer.DeserializeDocument(body); + model = _deserializer.Deserialize(body); } catch (JsonApiSerializationException exception) { diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index c4ea04002d..ef0caa311c 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -39,7 +39,7 @@ public RequestDeserializer( } /// - public object DeserializeDocument(string body) + public object Deserialize(string body) { if (body == null) throw new ArgumentNullException(nameof(body)); @@ -65,8 +65,6 @@ private object DeserializeOperationsDocument(string body) JToken bodyToken = LoadJToken(body); var document = bodyToken.ToObject(); - var operations = new List(); - if (document?.Operations == null || !document.Operations.Any()) { throw new JsonApiSerializationException("No operations found.", null); @@ -78,7 +76,9 @@ private object DeserializeOperationsDocument(string body) $"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); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs index 8977a9d221..d28a2d4f5e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs @@ -67,7 +67,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext => userAccountInDatabase.FirstName.Should().Be(existingUserAccount.FirstName); userAccountInDatabase.LastName.Should().Be(existingUserAccount.LastName); }); - } [Fact] diff --git a/test/UnitTests/Models/ResourceConstructionTests.cs b/test/UnitTests/Models/ResourceConstructionTests.cs index f68865d7be..b88ab98594 100644 --- a/test/UnitTests/Models/ResourceConstructionTests.cs +++ b/test/UnitTests/Models/ResourceConstructionTests.cs @@ -51,7 +51,7 @@ public void When_resource_has_default_constructor_it_must_succeed() string content = JsonConvert.SerializeObject(body); // Act - object result = serializer.DeserializeDocument(content); + object result = serializer.Deserialize(content); // Assert Assert.NotNull(result); @@ -82,7 +82,7 @@ public void When_resource_has_default_constructor_that_throws_it_must_fail() string content = JsonConvert.SerializeObject(body); // Act - Action action = () => serializer.DeserializeDocument(content); + Action action = () => serializer.Deserialize(content); // Assert var exception = Assert.Throws(action); @@ -120,7 +120,7 @@ public void When_resource_has_constructor_with_injectable_parameter_it_must_succ string content = JsonConvert.SerializeObject(body); // Act - object result = serializer.DeserializeDocument(content); + object result = serializer.Deserialize(content); // Assert Assert.NotNull(result); @@ -152,7 +152,7 @@ public void When_resource_has_constructor_with_string_parameter_it_must_fail() string content = JsonConvert.SerializeObject(body); // Act - Action action = () => serializer.DeserializeDocument(content); + Action action = () => serializer.Deserialize(content); // Assert var exception = Assert.Throws(action); diff --git a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs b/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs index cf7bba316d..da492a2938 100644 --- a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs +++ b/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs @@ -31,7 +31,7 @@ public void DeserializeAttributes_VariousUpdatedMembers_RegistersTargetedFields( var body = JsonConvert.SerializeObject(content); // Act - _deserializer.DeserializeDocument(body); + _deserializer.Deserialize(body); // Assert Assert.Equal(5, attributesToUpdate.Count); @@ -51,7 +51,7 @@ public void DeserializeRelationships_MultipleDependentRelationships_RegistersUpd var body = JsonConvert.SerializeObject(content); // Act - _deserializer.DeserializeDocument(body); + _deserializer.Deserialize(body); // Assert Assert.Equal(4, relationshipsToUpdate.Count); @@ -71,7 +71,7 @@ public void DeserializeRelationships_MultiplePrincipalRelationships_RegistersUpd var body = JsonConvert.SerializeObject(content); // Act - _deserializer.DeserializeDocument(body); + _deserializer.Deserialize(body); // Assert Assert.Equal(4, relationshipsToUpdate.Count); From 4291a90682b34b0b6e92492fac3a83d8887e065c Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 20 Jan 2021 15:04:02 +0100 Subject: [PATCH 064/123] Renames --- .../Controllers/OperationsController.cs | 2 +- .../EntityFrameworkCoreTransaction.cs | 8 +------- .../EntityFrameworkCoreTransactionFactory.cs | 4 ++-- ...tionsProcessor.cs => IOperationsProcessor.cs} | 2 +- ...sTransaction.cs => IOperationsTransaction.cs} | 7 +------ ...ctory.cs => IOperationsTransactionFactory.cs} | 4 ++-- .../MissingTransactionFactory.cs | 6 ++++-- ...ationsProcessor.cs => OperationsProcessor.cs} | 14 +++++++------- .../Processors/IAddToRelationshipProcessor.cs | 2 +- .../Processors/ICreateProcessor.cs | 2 +- .../Processors/IDeleteProcessor.cs | 2 +- ...rationProcessor.cs => IOperationProcessor.cs} | 2 +- .../IRemoveFromRelationshipProcessor.cs | 2 +- .../Processors/ISetRelationshipProcessor.cs | 2 +- .../Processors/IUpdateProcessor.cs | 2 +- .../IAtomicOperationProcessorResolver.cs | 16 ---------------- .../Configuration/IOperationProcessorResolver.cs | 16 ++++++++++++++++ .../Configuration/JsonApiApplicationBuilder.cs | 9 +++++---- ...Resolver.cs => OperationProcessorResolver.cs} | 10 +++++----- .../BaseJsonApiOperationsController.cs | 6 +++--- .../Controllers/JsonApiOperationsController.cs | 2 +- .../CreateMusicTrackOperationsController.cs | 2 +- 22 files changed, 57 insertions(+), 65 deletions(-) rename src/JsonApiDotNetCore/AtomicOperations/{IAtomicOperationsProcessor.cs => IOperationsProcessor.cs} (91%) rename src/JsonApiDotNetCore/AtomicOperations/{IAtomicOperationsTransaction.cs => IOperationsTransaction.cs} (74%) rename src/JsonApiDotNetCore/AtomicOperations/{IAtomicOperationsTransactionFactory.cs => IOperationsTransactionFactory.cs} (67%) rename src/JsonApiDotNetCore/AtomicOperations/{AtomicOperationsProcessor.cs => OperationsProcessor.cs} (87%) rename src/JsonApiDotNetCore/AtomicOperations/Processors/{IAtomicOperationProcessor.cs => IOperationProcessor.cs} (88%) delete mode 100644 src/JsonApiDotNetCore/Configuration/IAtomicOperationProcessorResolver.cs create mode 100644 src/JsonApiDotNetCore/Configuration/IOperationProcessorResolver.cs rename src/JsonApiDotNetCore/Configuration/{AtomicOperationProcessorResolver.cs => OperationProcessorResolver.cs} (82%) diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs index 4a146ce58c..247bd52d78 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCoreExample.Controllers public sealed class OperationsController : JsonApiOperationsController { public OperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IAtomicOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) + 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 index 45a7c65619..7299d2d3ac 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCore.AtomicOperations /// /// Represents an Entity Framework Core transaction in an atomic:operations request. /// - public sealed class EntityFrameworkCoreTransaction : IAtomicOperationsTransaction + public sealed class EntityFrameworkCoreTransaction : IOperationsTransaction { private readonly IDbContextTransaction _transaction; private readonly DbContext _dbContext; @@ -38,12 +38,6 @@ public Task CommitAsync(CancellationToken cancellationToken) return _transaction.CommitAsync(cancellationToken); } - /// - public Task RollbackAsync(CancellationToken cancellationToken) - { - return _transaction.RollbackAsync(cancellationToken); - } - /// public ValueTask DisposeAsync() { diff --git a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs index 267fff1b1b..07f671d3e2 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs @@ -8,7 +8,7 @@ namespace JsonApiDotNetCore.AtomicOperations /// /// Provides transaction support for atomic:operation requests using Entity Framework Core. /// - public sealed class EntityFrameworkCoreTransactionFactory : IAtomicOperationsTransactionFactory + public sealed class EntityFrameworkCoreTransactionFactory : IOperationsTransactionFactory { private readonly IDbContextResolver _dbContextResolver; @@ -18,7 +18,7 @@ public EntityFrameworkCoreTransactionFactory(IDbContextResolver dbContextResolve } /// - public async Task BeginTransactionAsync(CancellationToken cancellationToken) + public async Task BeginTransactionAsync(CancellationToken cancellationToken) { var dbContext = _dbContextResolver.GetContext(); var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); diff --git a/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/IOperationsProcessor.cs similarity index 91% rename from src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsProcessor.cs rename to src/JsonApiDotNetCore/AtomicOperations/IOperationsProcessor.cs index 9429cad3ea..839d0d6cb0 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/IOperationsProcessor.cs @@ -8,7 +8,7 @@ namespace JsonApiDotNetCore.AtomicOperations /// /// Atomically processes a request that contains a list of operations. /// - public interface IAtomicOperationsProcessor + public interface IOperationsProcessor { /// /// Processes the list of specified operations. diff --git a/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsTransaction.cs b/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransaction.cs similarity index 74% rename from src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsTransaction.cs rename to src/JsonApiDotNetCore/AtomicOperations/IOperationsTransaction.cs index 44ad21d58e..337a79cf4c 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsTransaction.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransaction.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCore.AtomicOperations /// /// Represents the overarching transaction in an atomic:operations request. /// - public interface IAtomicOperationsTransaction : IAsyncDisposable + public interface IOperationsTransaction : IAsyncDisposable { /// /// Identifies the active transaction. @@ -23,10 +23,5 @@ public interface IAtomicOperationsTransaction : IAsyncDisposable /// Commits all changes made to the underlying data store. /// Task CommitAsync(CancellationToken cancellationToken); - - /// - /// Discards all changes made to the underlying data store. - /// - Task RollbackAsync(CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsTransactionFactory.cs b/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransactionFactory.cs similarity index 67% rename from src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsTransactionFactory.cs rename to src/JsonApiDotNetCore/AtomicOperations/IOperationsTransactionFactory.cs index 8f178c7b31..f9b752381b 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationsTransactionFactory.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransactionFactory.cs @@ -6,11 +6,11 @@ namespace JsonApiDotNetCore.AtomicOperations /// /// Provides a method to start the overarching transaction for an atomic:operations request. /// - public interface IAtomicOperationsTransactionFactory + public interface IOperationsTransactionFactory { /// /// Starts a new transaction. /// - Task BeginTransactionAsync(CancellationToken cancellationToken); + Task BeginTransactionAsync(CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/MissingTransactionFactory.cs b/src/JsonApiDotNetCore/AtomicOperations/MissingTransactionFactory.cs index 56ceaf9088..b59338fab2 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/MissingTransactionFactory.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/MissingTransactionFactory.cs @@ -7,10 +7,12 @@ 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 : IAtomicOperationsTransactionFactory + public sealed class MissingTransactionFactory : IOperationsTransactionFactory { - public Task BeginTransactionAsync(CancellationToken cancellationToken) + 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/AtomicOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs similarity index 87% rename from src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs rename to src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs index b915e89d52..c94563d840 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/AtomicOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs @@ -12,25 +12,25 @@ namespace JsonApiDotNetCore.AtomicOperations { /// - public class AtomicOperationsProcessor : IAtomicOperationsProcessor + public class OperationsProcessor : IOperationsProcessor { - private readonly IAtomicOperationProcessorResolver _resolver; + private readonly IOperationProcessorResolver _resolver; private readonly ILocalIdTracker _localIdTracker; private readonly IJsonApiRequest _request; private readonly ITargetedFields _targetedFields; private readonly IResourceContextProvider _resourceContextProvider; - private readonly IAtomicOperationsTransactionFactory _atomicOperationsTransactionFactory; + private readonly IOperationsTransactionFactory _operationsTransactionFactory; - public AtomicOperationsProcessor(IAtomicOperationProcessorResolver resolver, + public OperationsProcessor(IOperationProcessorResolver resolver, ILocalIdTracker localIdTracker, IJsonApiRequest request, ITargetedFields targetedFields, - IResourceContextProvider resourceContextProvider, IAtomicOperationsTransactionFactory atomicOperationsTransactionFactory) + IResourceContextProvider resourceContextProvider, IOperationsTransactionFactory operationsTransactionFactory) { _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); _localIdTracker = localIdTracker ?? throw new ArgumentNullException(nameof(localIdTracker)); _request = request ?? throw new ArgumentNullException(nameof(request)); _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); - _atomicOperationsTransactionFactory = atomicOperationsTransactionFactory ?? throw new ArgumentNullException(nameof(atomicOperationsTransactionFactory)); + _operationsTransactionFactory = operationsTransactionFactory ?? throw new ArgumentNullException(nameof(operationsTransactionFactory)); } /// @@ -42,7 +42,7 @@ public async Task> ProcessAsync(IList(); - await using var transaction = await _atomicOperationsTransactionFactory.BeginTransactionAsync(cancellationToken); + await using var transaction = await _operationsTransactionFactory.BeginTransactionAsync(cancellationToken); try { foreach (var operation in operations) diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs index e9cfaf153c..f5a6280d50 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs @@ -13,7 +13,7 @@ public interface IAddToRelationshipProcessor : IAddToRelationshipProc /// /// The resource type. /// The resource identifier type. - public interface IAddToRelationshipProcessor : IAtomicOperationProcessor + public interface IAddToRelationshipProcessor : IOperationProcessor where TResource : class, IIdentifiable { } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs index 9abc968b6f..c4f51b9150 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs @@ -13,7 +13,7 @@ public interface ICreateProcessor : ICreateProcessor /// /// The resource type. /// The resource identifier type. - public interface ICreateProcessor : IAtomicOperationProcessor + public interface ICreateProcessor : IOperationProcessor where TResource : class, IIdentifiable { } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs index 4e26c8a67a..973a8cb28b 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs @@ -13,7 +13,7 @@ public interface IDeleteProcessor : IDeleteProcessor /// /// The resource type. /// The resource identifier type. - public interface IDeleteProcessor : IAtomicOperationProcessor + public interface IDeleteProcessor : IOperationProcessor where TResource : class, IIdentifiable { } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IAtomicOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs similarity index 88% rename from src/JsonApiDotNetCore/AtomicOperations/Processors/IAtomicOperationProcessor.cs rename to src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs index eea1dea860..6f49d94443 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IAtomicOperationProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors /// /// Processes a single entry in a list of operations. /// - public interface IAtomicOperationProcessor + public interface IOperationProcessor { Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs index 40a87cd5df..62727d50c9 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs @@ -13,7 +13,7 @@ public interface IRemoveFromRelationshipProcessor : IRemoveFromRelati /// /// /// - public interface IRemoveFromRelationshipProcessor : IAtomicOperationProcessor + public interface IRemoveFromRelationshipProcessor : IOperationProcessor where TResource : class, IIdentifiable { } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs index 160215fc36..bd23d0c951 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs @@ -13,7 +13,7 @@ public interface ISetRelationshipProcessor : ISetRelationshipProcesso /// /// The resource type. /// The resource identifier type. - public interface ISetRelationshipProcessor : IAtomicOperationProcessor + public interface ISetRelationshipProcessor : IOperationProcessor where TResource : class, IIdentifiable { } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs index f95ca5431d..7d71b364cc 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs @@ -14,7 +14,7 @@ public interface IUpdateProcessor : IUpdateProcessor /// /// The resource type. /// The resource identifier type. - public interface IUpdateProcessor : IAtomicOperationProcessor + public interface IUpdateProcessor : IOperationProcessor where TResource : class, IIdentifiable { } diff --git a/src/JsonApiDotNetCore/Configuration/IAtomicOperationProcessorResolver.cs b/src/JsonApiDotNetCore/Configuration/IAtomicOperationProcessorResolver.cs deleted file mode 100644 index 6931cd6fbb..0000000000 --- a/src/JsonApiDotNetCore/Configuration/IAtomicOperationProcessorResolver.cs +++ /dev/null @@ -1,16 +0,0 @@ -using JsonApiDotNetCore.AtomicOperations.Processors; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Configuration -{ - /// - /// Used to resolve a compatible at runtime, based on the operation kind. - /// - public interface IAtomicOperationProcessorResolver - { - /// - /// Resolves a compatible . - /// - IAtomicOperationProcessor ResolveProcessor(OperationContainer operation); - } -} diff --git a/src/JsonApiDotNetCore/Configuration/IOperationProcessorResolver.cs b/src/JsonApiDotNetCore/Configuration/IOperationProcessorResolver.cs new file mode 100644 index 0000000000..4c48b0c426 --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/IOperationProcessorResolver.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.AtomicOperations.Processors; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Configuration +{ + /// + /// Used to resolve a compatible at runtime, based on the operation kind. + /// + public interface IOperationProcessorResolver + { + /// + /// Resolves a compatible . + /// + IOperationProcessor ResolveProcessor(OperationContainer operation); + } +} diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index a903600f71..f57159d492 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -24,6 +24,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using IOperationsProcessor = JsonApiDotNetCore.AtomicOperations.IOperationsProcessor; namespace JsonApiDotNetCore.Configuration { @@ -133,11 +134,11 @@ public void ConfigureServiceContainer(ICollection dbContextTypes) _services.AddScoped(typeof(IDbContextResolver), contextResolverType); } - _services.AddScoped(); + _services.AddScoped(); } else { - _services.AddScoped(); + _services.AddScoped(); } AddResourceLayer(); @@ -279,8 +280,8 @@ private void AddSerializationLayer() private void AddAtomicOperationsLayer() { - _services.AddScoped(); - _services.AddScoped(); + _services.AddScoped(); + _services.AddScoped(); _services.AddScoped(); _services.AddScoped(typeof(ICreateProcessor<>), typeof(CreateProcessor<>)); diff --git a/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs b/src/JsonApiDotNetCore/Configuration/OperationProcessorResolver.cs similarity index 82% rename from src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs rename to src/JsonApiDotNetCore/Configuration/OperationProcessorResolver.cs index e9502f4405..6be525e062 100644 --- a/src/JsonApiDotNetCore/Configuration/AtomicOperationProcessorResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/OperationProcessorResolver.cs @@ -6,12 +6,12 @@ namespace JsonApiDotNetCore.Configuration { /// - public class AtomicOperationProcessorResolver : IAtomicOperationProcessorResolver + public class OperationProcessorResolver : IOperationProcessorResolver { private readonly IGenericServiceFactory _genericServiceFactory; private readonly IResourceContextProvider _resourceContextProvider; - public AtomicOperationProcessorResolver(IGenericServiceFactory genericServiceFactory, + public OperationProcessorResolver(IGenericServiceFactory genericServiceFactory, IResourceContextProvider resourceContextProvider) { _genericServiceFactory = genericServiceFactory ?? throw new ArgumentNullException(nameof(genericServiceFactory)); @@ -19,7 +19,7 @@ public AtomicOperationProcessorResolver(IGenericServiceFactory genericServiceFac } /// - public IAtomicOperationProcessor ResolveProcessor(OperationContainer operation) + public IOperationProcessor ResolveProcessor(OperationContainer operation) { if (operation == null) throw new ArgumentNullException(nameof(operation)); @@ -44,11 +44,11 @@ public IAtomicOperationProcessor ResolveProcessor(OperationContainer operation) } } - private IAtomicOperationProcessor Resolve(OperationContainer operation, Type processorInterface) + private IOperationProcessor Resolve(OperationContainer operation, Type processorInterface) { var resourceContext = _resourceContextProvider.GetResourceContext(operation.Resource.GetType()); - return _genericServiceFactory.Get(processorInterface, + return _genericServiceFactory.Get(processorInterface, resourceContext.ResourceType, resourceContext.IdentityType ); } diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs index fdd5bfa00f..3083a05e53 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs @@ -15,18 +15,18 @@ 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 . + /// See https://jsonapi.org/ext/atomic/ for details. Delegates work to . /// public abstract class BaseJsonApiOperationsController : CoreJsonApiController { private readonly IJsonApiOptions _options; - private readonly IAtomicOperationsProcessor _processor; + private readonly IOperationsProcessor _processor; private readonly IJsonApiRequest _request; private readonly ITargetedFields _targetedFields; private readonly TraceLogWriter _traceWriter; protected BaseJsonApiOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IAtomicOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) + IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) { if (loggerFactory == null) throw new ArgumentNullException(nameof(loggerFactory)); diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs index 7e010a5cb8..a2aed59e8f 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs @@ -18,7 +18,7 @@ namespace JsonApiDotNetCore.Controllers public abstract class JsonApiOperationsController : BaseJsonApiOperationsController { protected JsonApiOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IAtomicOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) + IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) : base(options, loggerFactory, processor, request, targetedFields) { } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs index 092eb43dfb..1a3db3a952 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs @@ -19,7 +19,7 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Contro public sealed class CreateMusicTrackOperationsController : JsonApiOperationsController { public CreateMusicTrackOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IAtomicOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) + IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) : base(options, loggerFactory, processor, request, targetedFields) { } From a5f191e26aa2687bcd88a10b0ba7016091a10c61 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 20 Jan 2021 15:53:19 +0100 Subject: [PATCH 065/123] Simplified processors --- ...sor.cs => OperationContainerExtensions.cs} | 13 +++++--- .../Processors/AddToRelationshipProcessor.cs | 28 +++-------------- .../Processors/CreateProcessor.cs | 21 +------------ .../Processors/DeleteProcessor.cs | 16 ++-------- .../Processors/IAddToRelationshipProcessor.cs | 6 ---- .../Processors/ICreateProcessor.cs | 6 ---- .../Processors/IDeleteProcessor.cs | 6 ---- .../IRemoveFromRelationshipProcessor.cs | 6 ---- .../Processors/ISetRelationshipProcessor.cs | 6 ---- .../Processors/IUpdateProcessor.cs | 6 ---- .../RemoveFromRelationshipProcessor.cs | 31 +++++-------------- .../Processors/SetRelationshipProcessor.cs | 31 +++++-------------- .../Processors/UpdateProcessor.cs | 22 +------------ .../Configuration/IGenericServiceFactory.cs | 2 +- .../JsonApiApplicationBuilder.cs | 11 ------- .../OperationProcessorResolver.cs | 2 -- 16 files changed, 33 insertions(+), 180 deletions(-) rename src/JsonApiDotNetCore/AtomicOperations/{Processors/BaseRelationshipProcessor.cs => OperationContainerExtensions.cs} (66%) diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/BaseRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationContainerExtensions.cs similarity index 66% rename from src/JsonApiDotNetCore/AtomicOperations/Processors/BaseRelationshipProcessor.cs rename to src/JsonApiDotNetCore/AtomicOperations/OperationContainerExtensions.cs index 685c17d558..116e0fc406 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/BaseRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationContainerExtensions.cs @@ -1,14 +1,17 @@ +using System; using System.Collections.Generic; using System.Linq; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.AtomicOperations.Processors +namespace JsonApiDotNetCore.AtomicOperations { - public abstract class BaseRelationshipProcessor + public static class OperationContainerExtensions { - protected ISet GetSecondaryResourceIds(OperationContainer operation) + public static ISet GetSecondaryResourceIds(this OperationContainer operation) { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + var relationship = operation.Request.Relationship; var rightValue = relationship.GetValue(operation.Resource); @@ -16,8 +19,10 @@ protected ISet GetSecondaryResourceIds(OperationContainer operati return rightResources.ToHashSet(IdentifiableComparer.Instance); } - protected object GetSecondaryResourceIdOrIds(OperationContainer operation) + public static object GetSecondaryResourceIdOrIds(this OperationContainer operation) { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + var relationship = operation.Request.Relationship; var rightValue = relationship.GetValue(operation.Resource); diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs index 1bcfd7edf7..9ca044f984 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs @@ -6,13 +6,8 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors { - /// - /// Processes a single operation to add resources to a to-many relationship. - /// - /// The resource type. - /// The resource identifier type. - public class AddToRelationshipProcessor - : BaseRelationshipProcessor, IAddToRelationshipProcessor + /// + public class AddToRelationshipProcessor : IAddToRelationshipProcessor where TResource : class, IIdentifiable { private readonly IAddToRelationshipService _service; @@ -28,25 +23,12 @@ public async Task ProcessAsync(OperationContainer operation, if (operation == null) throw new ArgumentNullException(nameof(operation)); var primaryId = (TId) operation.Resource.GetTypedId(); - var secondaryResourceIds = GetSecondaryResourceIds(operation); + var secondaryResourceIds = operation.GetSecondaryResourceIds(); - await _service.AddToToManyRelationshipAsync(primaryId, operation.Request.Relationship.PublicName, secondaryResourceIds, cancellationToken); + await _service.AddToToManyRelationshipAsync(primaryId, operation.Request.Relationship.PublicName, + secondaryResourceIds, cancellationToken); return null; } } - - /// - /// Processes a single operation to add resources to a to-many relationship. - /// - /// The resource type. - public class AddToRelationshipProcessor - : AddToRelationshipProcessor, IAddToRelationshipProcessor - where TResource : class, IIdentifiable - { - public AddToRelationshipProcessor(IAddToRelationshipService service) - : base(service) - { - } - } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs index 3ce4697944..917422ca8b 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs @@ -7,11 +7,7 @@ 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 class CreateProcessor : ICreateProcessor where TResource : class, IIdentifiable { @@ -46,19 +42,4 @@ public async Task ProcessAsync(OperationContainer operation, return newResource == null ? null : operation.WithResource(newResource); } } - - /// - /// Processes a single operation to create a new resource with attributes, relationships or both. - /// - /// The resource type. - public class CreateProcessor - : CreateProcessor, ICreateProcessor - where TResource : class, IIdentifiable - { - public CreateProcessor(ICreateService service, ILocalIdTracker localIdTracker, - IResourceContextProvider resourceContextProvider) - : base(service, localIdTracker, resourceContextProvider) - { - } - } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs index b692e334ea..aba81211f0 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs @@ -18,7 +18,8 @@ public DeleteProcessor(IDeleteService service) } /// - public async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public async Task ProcessAsync(OperationContainer operation, + CancellationToken cancellationToken) { if (operation == null) throw new ArgumentNullException(nameof(operation)); @@ -28,17 +29,4 @@ public async Task ProcessAsync(OperationContainer operation, return null; } } - - /// - /// Processes a single operation to delete an existing resource. - /// - /// The resource type. - public class DeleteProcessor : DeleteProcessor, IDeleteProcessor - where TResource : class, IIdentifiable - { - public DeleteProcessor(IDeleteService service) - : base(service) - { - } - } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs index f5a6280d50..965c704e0a 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs @@ -2,12 +2,6 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors { - /// - public interface IAddToRelationshipProcessor : IAddToRelationshipProcessor - where TResource : class, IIdentifiable - { - } - /// /// Processes a single operation to add resources to a to-many relationship. /// diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs index c4f51b9150..846d6fc39a 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs @@ -2,12 +2,6 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors { - /// - public interface ICreateProcessor : ICreateProcessor - where TResource : class, IIdentifiable - { - } - /// /// Processes a single operation to create a new resource with attributes, relationships or both. /// diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs index 973a8cb28b..bb3efc30b0 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs @@ -2,12 +2,6 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors { - /// - public interface IDeleteProcessor : IDeleteProcessor - where TResource : class, IIdentifiable - { - } - /// /// Processes a single operation to delete an existing resource. /// diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs index 62727d50c9..ae5d80c55a 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs @@ -2,12 +2,6 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors { - /// - public interface IRemoveFromRelationshipProcessor : IRemoveFromRelationshipProcessor - where TResource : class, IIdentifiable - { - } - /// /// Processes a single operation to remove resources from a to-many relationship. /// diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs index bd23d0c951..f99c91d672 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs @@ -2,12 +2,6 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors { - /// - public interface ISetRelationshipProcessor : ISetRelationshipProcessor - where TResource : class, IIdentifiable - { - } - /// /// Processes a single operation to perform a complete replacement of a relationship on an existing resource. /// diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs index 7d71b364cc..f4f403c073 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs @@ -2,12 +2,6 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors { - /// - public interface IUpdateProcessor : IUpdateProcessor - where TResource : class, IIdentifiable - { - } - /// /// 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. diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs index c013bcb07f..cdbfb4bb45 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs @@ -6,13 +6,8 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors { - /// - /// Processes a single operation to remove resources from a to-many relationship. - /// - /// - /// - public class RemoveFromRelationshipProcessor - : BaseRelationshipProcessor, IRemoveFromRelationshipProcessor + /// + public class RemoveFromRelationshipProcessor : IRemoveFromRelationshipProcessor where TResource : class, IIdentifiable { private readonly IRemoveFromRelationshipService _service; @@ -23,30 +18,18 @@ public RemoveFromRelationshipProcessor(IRemoveFromRelationshipService - public async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public async Task ProcessAsync(OperationContainer operation, + CancellationToken cancellationToken) { if (operation == null) throw new ArgumentNullException(nameof(operation)); var primaryId = (TId) operation.Resource.GetTypedId(); - var secondaryResourceIds = GetSecondaryResourceIds(operation); + var secondaryResourceIds = operation.GetSecondaryResourceIds(); - await _service.RemoveFromToManyRelationshipAsync(primaryId, operation.Request.Relationship.PublicName, secondaryResourceIds, cancellationToken); + await _service.RemoveFromToManyRelationshipAsync(primaryId, operation.Request.Relationship.PublicName, + secondaryResourceIds, cancellationToken); return null; } } - - /// - /// Processes a single operation to add resources to a to-many relationship. - /// - /// The resource type. - public class RemoveFromRelationshipProcessor - : RemoveFromRelationshipProcessor, IAddToRelationshipProcessor - where TResource : class, IIdentifiable - { - public RemoveFromRelationshipProcessor(IRemoveFromRelationshipService service) - : base(service) - { - } - } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs index 38f5a06f0b..393ecb2ed1 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs @@ -6,13 +6,8 @@ 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 class SetRelationshipProcessor - : BaseRelationshipProcessor, ISetRelationshipProcessor + /// + public class SetRelationshipProcessor : ISetRelationshipProcessor where TResource : class, IIdentifiable { private readonly ISetRelationshipService _service; @@ -23,30 +18,18 @@ public SetRelationshipProcessor(ISetRelationshipService service) } /// - public async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public async Task ProcessAsync(OperationContainer operation, + CancellationToken cancellationToken) { if (operation == null) throw new ArgumentNullException(nameof(operation)); var primaryId = (TId) operation.Resource.GetTypedId(); - object secondaryResourceIds = GetSecondaryResourceIdOrIds(operation); + object secondaryResourceIds = operation.GetSecondaryResourceIdOrIds(); - await _service.SetRelationshipAsync(primaryId, operation.Request.Relationship.PublicName, secondaryResourceIds, cancellationToken); + await _service.SetRelationshipAsync(primaryId, operation.Request.Relationship.PublicName, + secondaryResourceIds, cancellationToken); return null; } } - - /// - /// Processes a single operation to perform a complete replacement of a relationship on an existing resource. - /// - /// The resource type. - public class SetRelationshipProcessor - : SetRelationshipProcessor, IUpdateProcessor - where TResource : class, IIdentifiable - { - public SetRelationshipProcessor(ISetRelationshipService service) - : base(service) - { - } - } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs index 583d9052e2..5350ea0fb5 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs @@ -6,12 +6,7 @@ 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 class UpdateProcessor : IUpdateProcessor where TResource : class, IIdentifiable { @@ -33,19 +28,4 @@ public async Task ProcessAsync(OperationContainer operation, return updated == null ? null : operation.WithResource(updated); } } - - /// - /// 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. - public class UpdateProcessor - : UpdateProcessor, IUpdateProcessor - where TResource : class, IIdentifiable - { - public UpdateProcessor(IUpdateService service) - : base(service) - { - } - } } diff --git a/src/JsonApiDotNetCore/Configuration/IGenericServiceFactory.cs b/src/JsonApiDotNetCore/Configuration/IGenericServiceFactory.cs index 29e5f18c02..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 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/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index f57159d492..5e28ee9d58 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -284,22 +284,11 @@ private void AddAtomicOperationsLayer() _services.AddScoped(); _services.AddScoped(); - _services.AddScoped(typeof(ICreateProcessor<>), typeof(CreateProcessor<>)); _services.AddScoped(typeof(ICreateProcessor<,>), typeof(CreateProcessor<,>)); - - _services.AddScoped(typeof(IUpdateProcessor<>), typeof(UpdateProcessor<>)); _services.AddScoped(typeof(IUpdateProcessor<,>), typeof(UpdateProcessor<,>)); - - _services.AddScoped(typeof(IDeleteProcessor<>), typeof(DeleteProcessor<>)); _services.AddScoped(typeof(IDeleteProcessor<,>), typeof(DeleteProcessor<,>)); - - _services.AddScoped(typeof(IAddToRelationshipProcessor<>), typeof(AddToRelationshipProcessor<>)); _services.AddScoped(typeof(IAddToRelationshipProcessor<,>), typeof(AddToRelationshipProcessor<,>)); - - _services.AddScoped(typeof(ISetRelationshipProcessor<>), typeof(SetRelationshipProcessor<>)); _services.AddScoped(typeof(ISetRelationshipProcessor<,>), typeof(SetRelationshipProcessor<,>)); - - _services.AddScoped(typeof(IRemoveFromRelationshipProcessor<>), typeof(RemoveFromRelationshipProcessor<>)); _services.AddScoped(typeof(IRemoveFromRelationshipProcessor<,>), typeof(RemoveFromRelationshipProcessor<,>)); } diff --git a/src/JsonApiDotNetCore/Configuration/OperationProcessorResolver.cs b/src/JsonApiDotNetCore/Configuration/OperationProcessorResolver.cs index 6be525e062..f771e73e00 100644 --- a/src/JsonApiDotNetCore/Configuration/OperationProcessorResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/OperationProcessorResolver.cs @@ -23,8 +23,6 @@ public IOperationProcessor ResolveProcessor(OperationContainer operation) { if (operation == null) throw new ArgumentNullException(nameof(operation)); - // TODO: @OPS: How about processors with a single type argument? - switch (operation.Kind) { case OperationKind.CreateResource: From b95cc8835df0552d5e0572fc33060069fc78e071 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 20 Jan 2021 16:09:30 +0100 Subject: [PATCH 066/123] Removed dependency on GenericServiceFactory --- .../IOperationProcessorAccessor.cs | 18 ++++++ .../OperationProcessorAccessor.cs | 61 +++++++++++++++++++ .../AtomicOperations/OperationsProcessor.cs | 21 +++---- .../IOperationProcessorResolver.cs | 16 ----- .../JsonApiApplicationBuilder.cs | 2 +- .../OperationProcessorResolver.cs | 54 ---------------- .../IResourceRepositoryAccessor.cs | 2 +- 7 files changed, 91 insertions(+), 83 deletions(-) create mode 100644 src/JsonApiDotNetCore/AtomicOperations/IOperationProcessorAccessor.cs create mode 100644 src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs delete mode 100644 src/JsonApiDotNetCore/Configuration/IOperationProcessorResolver.cs delete mode 100644 src/JsonApiDotNetCore/Configuration/OperationProcessorResolver.cs 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/OperationProcessorAccessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs new file mode 100644 index 0000000000..934c9d9ac4 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs @@ -0,0 +1,61 @@ +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) + { + 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 index c94563d840..827f22ab12 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs @@ -14,23 +14,23 @@ namespace JsonApiDotNetCore.AtomicOperations /// public class OperationsProcessor : IOperationsProcessor { - private readonly IOperationProcessorResolver _resolver; + 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 IResourceContextProvider _resourceContextProvider; - private readonly IOperationsTransactionFactory _operationsTransactionFactory; - public OperationsProcessor(IOperationProcessorResolver resolver, - ILocalIdTracker localIdTracker, IJsonApiRequest request, ITargetedFields targetedFields, - IResourceContextProvider resourceContextProvider, IOperationsTransactionFactory operationsTransactionFactory) + public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccessor, + IOperationsTransactionFactory operationsTransactionFactory, ILocalIdTracker localIdTracker, + IResourceContextProvider resourceContextProvider, IJsonApiRequest request, ITargetedFields targetedFields) { - _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver)); + _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)); - _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); - _operationsTransactionFactory = operationsTransactionFactory ?? throw new ArgumentNullException(nameof(operationsTransactionFactory)); } /// @@ -97,8 +97,7 @@ private async Task ProcessOperation(OperationContainer opera _request.CopyFrom(operation.Request); - var processor = _resolver.ResolveProcessor(operation); - return await processor.ProcessAsync(operation, cancellationToken); + return await _operationProcessorAccessor.ProcessAsync(operation, cancellationToken); } private void TrackLocalIds(OperationContainer operation) diff --git a/src/JsonApiDotNetCore/Configuration/IOperationProcessorResolver.cs b/src/JsonApiDotNetCore/Configuration/IOperationProcessorResolver.cs deleted file mode 100644 index 4c48b0c426..0000000000 --- a/src/JsonApiDotNetCore/Configuration/IOperationProcessorResolver.cs +++ /dev/null @@ -1,16 +0,0 @@ -using JsonApiDotNetCore.AtomicOperations.Processors; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Configuration -{ - /// - /// Used to resolve a compatible at runtime, based on the operation kind. - /// - public interface IOperationProcessorResolver - { - /// - /// Resolves a compatible . - /// - IOperationProcessor ResolveProcessor(OperationContainer operation); - } -} diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 5e28ee9d58..74dd3f0f45 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -281,7 +281,7 @@ private void AddSerializationLayer() private void AddAtomicOperationsLayer() { _services.AddScoped(); - _services.AddScoped(); + _services.AddScoped(); _services.AddScoped(); _services.AddScoped(typeof(ICreateProcessor<,>), typeof(CreateProcessor<,>)); diff --git a/src/JsonApiDotNetCore/Configuration/OperationProcessorResolver.cs b/src/JsonApiDotNetCore/Configuration/OperationProcessorResolver.cs deleted file mode 100644 index f771e73e00..0000000000 --- a/src/JsonApiDotNetCore/Configuration/OperationProcessorResolver.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System; -using JsonApiDotNetCore.AtomicOperations.Processors; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Configuration -{ - /// - public class OperationProcessorResolver : IOperationProcessorResolver - { - private readonly IGenericServiceFactory _genericServiceFactory; - private readonly IResourceContextProvider _resourceContextProvider; - - public OperationProcessorResolver(IGenericServiceFactory genericServiceFactory, - IResourceContextProvider resourceContextProvider) - { - _genericServiceFactory = genericServiceFactory ?? throw new ArgumentNullException(nameof(genericServiceFactory)); - _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); - } - - /// - public IOperationProcessor ResolveProcessor(OperationContainer operation) - { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - - switch (operation.Kind) - { - case OperationKind.CreateResource: - return Resolve(operation, typeof(ICreateProcessor<,>)); - case OperationKind.UpdateResource: - return Resolve(operation, typeof(IUpdateProcessor<,>)); - case OperationKind.DeleteResource: - return Resolve(operation, typeof(IDeleteProcessor<,>)); - case OperationKind.SetRelationship: - return Resolve(operation, typeof(ISetRelationshipProcessor<,>)); - case OperationKind.AddToRelationship: - return Resolve(operation, typeof(IAddToRelationshipProcessor<,>)); - case OperationKind.RemoveFromRelationship: - return Resolve(operation, typeof(IRemoveFromRelationshipProcessor<,>)); - default: - throw new NotSupportedException($"Unknown operation kind '{operation.Kind}'."); - } - } - - private IOperationProcessor Resolve(OperationContainer operation, Type processorInterface) - { - var resourceContext = _resourceContextProvider.GetResourceContext(operation.Resource.GetType()); - - return _genericServiceFactory.Get(processorInterface, - resourceContext.ResourceType, resourceContext.IdentityType - ); - } - } -} 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 { From 911957560d24bc8dde73f6e0569deeae10c6749a Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 20 Jan 2021 16:36:58 +0100 Subject: [PATCH 067/123] Corrected error message --- .../JsonApiApplicationBuilder.cs | 1 - .../Controllers/BaseJsonApiController.cs | 2 +- .../BaseJsonApiOperationsController.cs | 2 +- ...reateResourceRequestNotAllowedException.cs | 27 +++++++++++++++++++ ...ourceIdInPostRequestNotAllowedException.cs | 25 ----------------- .../Creating/AtomicCreateResourceTests.cs | 2 +- 6 files changed, 30 insertions(+), 29 deletions(-) create mode 100644 src/JsonApiDotNetCore/Errors/ResourceIdInCreateResourceRequestNotAllowedException.cs delete mode 100644 src/JsonApiDotNetCore/Errors/ResourceIdInPostRequestNotAllowedException.cs diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 74dd3f0f45..d861286d4c 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -24,7 +24,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using IOperationsProcessor = JsonApiDotNetCore.AtomicOperations.IOperationsProcessor; namespace JsonApiDotNetCore.Configuration { diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index c3c966b4ce..b13953a4a9 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 ResourceIdInCreateResourceRequestNotAllowedException(); if (_options.ValidateModelState && !ModelState.IsValid) { diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs index 3083a05e53..b0a5956549 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs @@ -161,7 +161,7 @@ protected virtual void ValidateClientGeneratedIds(IEnumerable + /// The error that is thrown when a resource creation request is received that contains a client-generated ID. + /// + public sealed class ResourceIdInCreateResourceRequestNotAllowedException : JsonApiException + { + public ResourceIdInCreateResourceRequestNotAllowedException(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 257aeb81a4..0000000000 --- a/src/JsonApiDotNetCore/Errors/ResourceIdInPostRequestNotAllowedException.cs +++ /dev/null @@ -1,25 +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(int? atomicOperationIndex = null) - : base(new Error(HttpStatusCode.Forbidden) - { - Title = "Specifying the resource ID in POST requests is not allowed.", - Source = - { - Pointer = atomicOperationIndex != null - ? $"/atomic:operations[{atomicOperationIndex}]/data/id" - : "/data/id" - } - }) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index dbe62c332b..173f9151b9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -355,7 +355,7 @@ public async Task Cannot_create_resource_with_client_generated_ID() responseDocument.Errors.Should().HaveCount(1); responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); - responseDocument.Errors[0].Title.Should().Be("Specifying the resource ID in POST requests is not allowed."); + 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"); } From b8fec24606f0f2b3eaca57a67ddcf3efc2d88ce0 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 20 Jan 2021 17:42:08 +0100 Subject: [PATCH 068/123] API review --- .../JsonApiApplicationBuilder.cs | 14 +-- .../Middleware/IJsonApiRequest.cs | 2 +- .../Middleware/JsonApiMiddleware.cs | 8 +- .../Middleware/JsonApiRoutingConvention.cs | 2 +- .../IRepositorySupportsTransaction.cs | 2 +- .../AtomicOperationsResponseSerializer.cs | 2 - .../Objects/AtomicOperationObject.cs | 16 --- .../Serialization/RequestDeserializer.cs | 10 +- .../LinksWithoutNamespaceTests.cs | 8 +- .../AtomicCreateMusicTrackTests.cs | 9 +- .../Creating/AtomicCreateResourceTests.cs | 9 +- ...reateResourceWithClientGeneratedIdTests.cs | 8 +- ...eateResourceWithToManyRelationshipTests.cs | 9 +- ...reateResourceWithToOneRelationshipTests.cs | 9 +- .../Deleting/AtomicDeleteResourceTests.cs | 9 +- .../Links/AtomicAbsoluteLinksTests.cs | 5 +- .../AtomicRelativeLinksWithNamespaceTests.cs | 7 +- .../{Mixed => LocalIds}/AtomicLocalIdTests.cs | 11 +- .../Meta/AtomicResourceMetaTests.cs | 5 +- .../Meta/AtomicResponseMetaTests.cs | 5 +- ...ionsTests.cs => AtomicRequestBodyTests.cs} | 97 +--------------- .../Mixed/MaximumOperationsPerRequestTests.cs | 8 +- .../AtomicModelStateValidationTests.cs | 9 +- .../QueryStrings/AtomicQueryStringTests.cs | 5 +- ...cSparseFieldSetResourceDefinitionTests.cs} | 9 +- .../Transactions/AtomicRollbackTests.cs | 106 ++++++++++++++++++ ...s => AtomicTransactionConsistencyTests.cs} | 9 +- .../AtomicAddToToManyRelationshipTests.cs | 9 +- ...AtomicRemoveFromToManyRelationshipTests.cs | 9 +- .../AtomicReplaceToManyRelationshipTests.cs | 9 +- .../AtomicUpdateToOneRelationshipTests.cs | 9 +- .../AtomicReplaceToManyRelationshipTests.cs | 9 +- .../Resources/AtomicUpdateResourceTests.cs | 9 +- .../AtomicUpdateToOneRelationshipTests.cs | 9 +- .../ContentNegotiation/AcceptHeaderTests.cs | 19 +--- .../ContentTypeHeaderTests.cs | 15 +-- .../ServiceCollectionExtensions.cs | 15 +++ 37 files changed, 185 insertions(+), 320 deletions(-) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/{Mixed => LocalIds}/AtomicLocalIdTests.cs (99%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/{MixedOperationsTests.cs => AtomicRequestBodyTests.cs} (60%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/{SparseFieldSetResourceDefinitionTests.cs => AtomicSparseFieldSetResourceDefinitionTests.cs} (92%) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/{TransactionConsistencyTests.cs => AtomicTransactionConsistencyTests.cs} (92%) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ServiceCollectionExtensions.cs diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index d861286d4c..25fe41bbd7 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -146,7 +146,7 @@ public void ConfigureServiceContainer(ICollection dbContextTypes) AddMiddlewareLayer(); AddSerializationLayer(); AddQueryStringLayer(); - AddAtomicOperationsLayer(); + AddOperationsLayer(); AddResourceHooks(); @@ -271,24 +271,24 @@ private void AddSerializationLayer() _services.AddScoped(); _services.AddScoped(); _services.AddScoped(); - _services.AddScoped(typeof(AtomicOperationsResponseSerializer)); _services.AddScoped(typeof(ResponseSerializer<>)); + _services.AddScoped(typeof(AtomicOperationsResponseSerializer)); _services.AddScoped(sp => sp.GetRequiredService().GetSerializer()); _services.AddScoped(); } - private void AddAtomicOperationsLayer() + private void AddOperationsLayer() { - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _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) diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs index 0feb09bc47..ecabf6d8eb 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs @@ -60,7 +60,7 @@ public interface IJsonApiRequest bool IsReadOnly { get; } /// - /// In case of an atomic:operations request, this indicates the operation currently being processed. + /// In case of an atomic:operations request, this indicates the kind of operation currently being processed. /// OperationKind? OperationKind { get; } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index 23d4ab05e0..838d234c09 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -60,7 +60,7 @@ public async Task Invoke(HttpContext httpContext, httpContext.RegisterJsonApiRequest(); } - else if (IsAtomicOperationsRequest(routeValues)) + else if (IsOperationsRequest(routeValues)) { if (!await ValidateContentTypeHeaderAsync(HeaderConstants.AtomicOperationsMediaType, httpContext, options.SerializerSettings) || !await ValidateAcceptHeaderAsync(_atomicOperationsMediaType, httpContext, options.SerializerSettings)) @@ -68,7 +68,7 @@ public async Task Invoke(HttpContext httpContext, return; } - SetupAtomicOperationsRequest((JsonApiRequest)request, options, httpContext.Request); + SetupOperationsRequest((JsonApiRequest)request, options, httpContext.Request); httpContext.RegisterJsonApiRequest(); } @@ -266,13 +266,13 @@ private static bool IsRouteForRelationship(RouteValueDictionary routeValues) return actionName.EndsWith("Relationship", StringComparison.Ordinal); } - private static bool IsAtomicOperationsRequest(RouteValueDictionary routeValues) + private static bool IsOperationsRequest(RouteValueDictionary routeValues) { var actionName = (string)routeValues["action"]; return actionName == "PostOperations"; } - private static void SetupAtomicOperationsRequest(JsonApiRequest request, IJsonApiOptions options, HttpRequest httpRequest) + private static void SetupOperationsRequest(JsonApiRequest request, IJsonApiOptions options, HttpRequest httpRequest) { request.IsReadOnly = false; request.Kind = EndpointKind.AtomicOperations; diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs index dfe78fce07..f751e2a6ec 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs @@ -71,7 +71,7 @@ public void Apply(ApplicationModel application) if (resourceType != null) { var resourceContext = _resourceContextProvider.GetResourceContext(resourceType); - + if (resourceContext != null) { _registeredResources.Add(controller.ControllerName, resourceContext); diff --git a/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs b/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs index 0fc893266e..d134e39bd9 100644 --- a/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs +++ b/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs @@ -3,7 +3,7 @@ namespace JsonApiDotNetCore.Repositories { /// - /// Used to indicate a supports execution inside a transaction. + /// Used to indicate that a supports execution inside a transaction. /// public interface IRepositorySupportsTransaction { diff --git a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs index 02336aed69..a656e709b9 100644 --- a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs @@ -1,10 +1,8 @@ using System; using System.Collections.Generic; -using System.Linq; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Building; using JsonApiDotNetCore.Serialization.Objects; diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs index df26bf7a47..baa787aed0 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Linq; using Newtonsoft.Json; using Newtonsoft.Json.Converters; @@ -21,20 +20,5 @@ public sealed class AtomicOperationObject : ExposableData [JsonProperty("href", NullValueHandling = NullValueHandling.Ignore)] public string Href { get; set; } - - public string GetResourceTypeName() - { - if (Ref != null) - { - return Ref.Type; - } - - if (IsManyData) - { - return ManyData.First().Type; - } - - return SingleData.Type; - } } } diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index ef0caa311c..bfb3f0f6e1 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -110,6 +110,7 @@ private OperationContainer DeserializeOperation(AtomicOperationObject operation) RelationshipAttribute relationshipInRef = null; ResourceContext resourceContextInRef = null; + string resourceName = null; if (operation.Ref != null) { @@ -120,6 +121,7 @@ private OperationContainer DeserializeOperation(AtomicOperationObject operation) } resourceContextInRef = GetExistingResourceContext(operation.Ref.Type); + resourceName = resourceContextInRef.PublicName; bool hasNone = operation.Ref.Id == null && operation.Ref.Lid == null; bool hasBoth = operation.Ref.Id != null && operation.Ref.Lid != null; @@ -236,6 +238,7 @@ private OperationContainer DeserializeOperation(AtomicOperationObject operation) } var resourceContextInData = GetExistingResourceContext(resourceObject.Type); + resourceName ??= resourceContextInData.PublicName; if (kind.IsRelationship() && relationshipInRef != null) { @@ -291,12 +294,11 @@ private OperationContainer DeserializeOperation(AtomicOperationObject operation) } } - return ToOperationContainer(operation, kind); + return ToOperationContainer(operation, resourceName, kind); } - private OperationContainer ToOperationContainer(AtomicOperationObject operation, OperationKind kind) + private OperationContainer ToOperationContainer(AtomicOperationObject operation, string resourceName, OperationKind kind) { - var resourceName = operation.GetResourceTypeName(); var primaryResourceContext = GetExistingResourceContext(resourceName); _targetedFields.Attributes.Clear(); @@ -309,7 +311,7 @@ private OperationContainer ToOperationContainer(AtomicOperationObject operation, case OperationKind.CreateResource: case OperationKind.UpdateResource: { - // TODO: @OPS: Chicken-and-egg problem: ParseResourceObject depends on _request.OperationKind, which is not built yet. + // TODO: @OPS: Chicken-and-egg problem: ParseResourceObject depends on _request.OperationKind, which is not fully built yet. ((JsonApiRequest) _request).OperationKind = kind; resource = ParseResourceObject(operation.SingleData); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithoutNamespaceTests.cs index 9670d5003a..cb8fd4ba6c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithoutNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithoutNamespaceTests.cs @@ -6,7 +6,7 @@ using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Mvc.ApplicationParts; +using JsonApiDotNetCoreExampleTests.IntegrationTests; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -21,11 +21,7 @@ public LinksWithoutNamespaceTests(IntegrationTestContext - { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); - }); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/AtomicCreateMusicTrackTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/AtomicCreateMusicTrackTests.cs index 9ddfceee2a..378d6e3252 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/AtomicCreateMusicTrackTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/AtomicCreateMusicTrackTests.cs @@ -2,9 +2,6 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; -using Microsoft.Extensions.DependencyInjection; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Controllers @@ -19,11 +16,7 @@ public AtomicCreateMusicTrackTests(IntegrationTestContext - { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); - }); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index 173f9151b9..f38b87e8c5 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -6,10 +6,7 @@ using FluentAssertions; using FluentAssertions.Extensions; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Creating @@ -24,11 +21,7 @@ public AtomicCreateResourceTests(IntegrationTestContext - { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); - }); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs index 19bc564995..358365a945 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -4,8 +4,6 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -22,11 +20,7 @@ public AtomicCreateResourceWithClientGeneratedIdTests(IntegrationTestContext - { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); - }); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); options.AllowClientGeneratedIds = true; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs index 28c03ce419..c053fb28cd 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs @@ -3,10 +3,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Creating @@ -21,11 +18,7 @@ public AtomicCreateResourceWithToManyRelationshipTests(IntegrationTestContext - { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); - }); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs index 66bfe8a3f6..5e6780f609 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs @@ -5,10 +5,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; using Xunit; @@ -24,11 +21,7 @@ public AtomicCreateResourceWithToOneRelationshipTests(IntegrationTestContext - { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); - }); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs index 6c6af39eb1..4cb1d550b5 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs @@ -5,10 +5,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Deleting @@ -23,11 +20,7 @@ public AtomicDeleteResourceTests(IntegrationTestContext - { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); - }); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs index 7157ba06ed..34678cbd31 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs @@ -3,8 +3,6 @@ using FluentAssertions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -22,8 +20,7 @@ public AtomicAbsoluteLinksTests(IntegrationTestContext { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + services.AddControllersFromTestProject(); services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); }); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs index 21e3c3cc0d..b8db1e633d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs @@ -4,8 +4,6 @@ using FluentAssertions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -23,8 +21,7 @@ public AtomicRelativeLinksWithNamespaceTests(IntegrationTestContext { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + services.AddControllersFromTestProject(); services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); }); @@ -76,7 +73,7 @@ public async Task Create_resource_with_side_effects_returns_relative_links() 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(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs index 946321b495..016d114e98 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicLocalIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs @@ -3,13 +3,10 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Mixed +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.LocalIds { public sealed class AtomicLocalIdTests : IClassFixture, OperationsDbContext>> @@ -21,11 +18,7 @@ public AtomicLocalIdTests(IntegrationTestContext - { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); - }); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs index c3887b077a..25e1274ba0 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs @@ -5,8 +5,6 @@ using FluentAssertions.Extensions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -24,8 +22,7 @@ public AtomicResourceMetaTests(IntegrationTestContext { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + services.AddControllersFromTestProject(); services.AddScoped, MusicTrackMetaDefinition>(); services.AddScoped, TextLanguageMetaDefinition>(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs index e0b70ecb5f..00f29c02a9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs @@ -5,8 +5,6 @@ using FluentAssertions; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json.Linq; using Xunit; @@ -24,8 +22,7 @@ public AtomicResponseMetaTests(IntegrationTestContext { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + services.AddControllersFromTestProject(); services.AddSingleton(); }); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs similarity index 60% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs index 53bb03730d..65399322e1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MixedOperationsTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs @@ -2,29 +2,21 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Mixed { - public sealed class MixedOperationsTests + public sealed class AtomicRequestBodyTests : IClassFixture, OperationsDbContext>> { private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); - public MixedOperationsTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicRequestBodyTests(IntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; - testContext.ConfigureServicesAfterStartup(services => - { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); - }); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); } [Fact] @@ -156,88 +148,5 @@ await _testContext.RunOnDatabaseAsync(async dbContext => tracksInDatabase.Should().BeEmpty(); }); } - - [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/Mixed/MaximumOperationsPerRequestTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs index c3b7f90630..f86f2bfd11 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs @@ -4,8 +4,6 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -21,11 +19,7 @@ public MaximumOperationsPerRequestTests(IntegrationTestContext - { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); - }); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs index 4776449beb..5d8847293e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs @@ -2,10 +2,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ModelStateValidation @@ -20,11 +17,7 @@ public AtomicModelStateValidationTests(IntegrationTestContext - { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); - }); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs index 5b2ed8cf0a..fb4c019cea 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs @@ -6,9 +6,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -28,8 +26,7 @@ public AtomicQueryStringTests(IntegrationTestContext { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + services.AddControllersFromTestProject(); services.AddSingleton(new FrozenSystemClock(_frozenTime)); services.AddScoped, MusicTrackReleaseDefinition>(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSetResourceDefinitionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/AtomicSparseFieldSetResourceDefinitionTests.cs similarity index 92% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSetResourceDefinitionTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/AtomicSparseFieldSetResourceDefinitionTests.cs index 23bf62cc90..db6c9f508d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSetResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/AtomicSparseFieldSetResourceDefinitionTests.cs @@ -3,27 +3,24 @@ using FluentAssertions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.Extensions.DependencyInjection; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions { - public sealed class SparseFieldSetResourceDefinitionTests + public sealed class AtomicSparseFieldSetResourceDefinitionTests : IClassFixture, OperationsDbContext>> { private readonly IntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public SparseFieldSetResourceDefinitionTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicSparseFieldSetResourceDefinitionTests(IntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; testContext.ConfigureServicesAfterStartup(services => { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + services.AddControllersFromTestProject(); services.AddSingleton(); services.AddScoped, LyricTextDefinition>(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs new file mode 100644 index 0000000000..cdc46b0ef3 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs @@ -0,0 +1,106 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Transactions +{ + public sealed class AtomicRollbackTests + : IClassFixture, OperationsDbContext>> + { + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicRollbackTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); + } + + [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/TransactionConsistencyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs similarity index 92% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/TransactionConsistencyTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs index ff5b190d3a..c32cc1da1c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/TransactionConsistencyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs @@ -4,27 +4,24 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Transactions { - public sealed class TransactionConsistencyTests + public sealed class AtomicTransactionConsistencyTests : IClassFixture, OperationsDbContext>> { private readonly IntegrationTestContext, OperationsDbContext> _testContext; - public TransactionConsistencyTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicTransactionConsistencyTests(IntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; testContext.ConfigureServicesAfterStartup(services => { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + services.AddControllersFromTestProject(); services.AddResourceRepository(); services.AddResourceRepository(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs index 26e941d05a..d51a94a938 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs @@ -4,10 +4,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Relationships @@ -22,11 +19,7 @@ public AtomicAddToToManyRelationshipTests(IntegrationTestContext - { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); - }); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs index dd2370d97d..aae094e043 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs @@ -4,10 +4,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Relationships @@ -22,11 +19,7 @@ public AtomicRemoveFromToManyRelationshipTests(IntegrationTestContext - { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); - }); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs index 979f49f74a..1ebd3bba79 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs @@ -4,10 +4,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Relationships @@ -22,11 +19,7 @@ public AtomicReplaceToManyRelationshipTests(IntegrationTestContext - { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); - }); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs index 890d68dc10..dc2e96acbb 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs @@ -3,10 +3,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Relationships @@ -21,11 +18,7 @@ public AtomicUpdateToOneRelationshipTests(IntegrationTestContext - { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); - }); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs index 2b72d3b1e1..fb6bbb712b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs @@ -4,10 +4,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Resources @@ -22,11 +19,7 @@ public AtomicReplaceToManyRelationshipTests(IntegrationTestContext - { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); - }); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs index c107b8ed0b..e6ab220d19 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs @@ -5,10 +5,7 @@ using FluentAssertions; using FluentAssertions.Extensions; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Resources @@ -23,11 +20,7 @@ public AtomicUpdateResourceTests(IntegrationTestContext - { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); - }); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs index 2c48c6b280..61bfab41d7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs @@ -3,10 +3,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Resources @@ -21,11 +18,7 @@ public AtomicUpdateToOneRelationshipTests(IntegrationTestContext - { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); - }); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs index fa848dc730..a8c5808500 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs @@ -4,9 +4,6 @@ using FluentAssertions; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; -using Microsoft.Extensions.DependencyInjection; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ContentNegotiation @@ -20,11 +17,7 @@ public AcceptHeaderTests(IntegrationTestContext { _testContext = testContext; - testContext.ConfigureServicesAfterStartup(services => - { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); - }); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); } [Fact] @@ -64,10 +57,10 @@ public async Task Permits_no_Accept_headers_at_operations_endpoint() } } }; - + var route = "/operations"; var contentType = HeaderConstants.AtomicOperationsMediaType; - + var acceptHeaders = new MediaTypeWithQualityHeaderValue[0]; // Act @@ -159,10 +152,10 @@ public async Task Permits_JsonApi_with_AtomicOperations_extension_in_Accept_head } } }; - + var route = "/operations"; var contentType = HeaderConstants.AtomicOperationsMediaType; - + var acceptHeaders = new[] { MediaTypeWithQualityHeaderValue.Parse("text/html"), @@ -228,7 +221,7 @@ public async Task Denies_JsonApi_in_Accept_headers_at_operations_endpoint() } } }; - + var route = "/operations"; var contentType = HeaderConstants.AtomicOperationsMediaType; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs index aec0f8a6b2..7350eee557 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs @@ -3,9 +3,6 @@ using FluentAssertions; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; -using Microsoft.Extensions.DependencyInjection; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ContentNegotiation @@ -19,11 +16,7 @@ public ContentTypeHeaderTests(IntegrationTestContext - { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); - }); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); } [Fact] @@ -62,7 +55,7 @@ public async Task Returns_JsonApi_ContentType_header_with_AtomicOperations_exten } } }; - + var route = "/operations"; // Act @@ -152,7 +145,7 @@ public async Task Permits_JsonApi_ContentType_header_with_AtomicOperations_exten } } }; - + var route = "/operations"; var contentType = HeaderConstants.AtomicOperationsMediaType; @@ -340,7 +333,7 @@ public async Task Denies_JsonApi_ContentType_header_at_operations_endpoint() } } }; - + var route = "/operations"; var contentType = HeaderConstants.MediaType; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ServiceCollectionExtensions.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..f0b214af83 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests +{ + public static class ServiceCollectionExtensions + { + public static void AddControllersFromTestProject(this IServiceCollection services) + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + } + } +} From 06b360453c04295d8244813cbf478eacd38a3b49 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 21 Jan 2021 12:37:32 +0100 Subject: [PATCH 069/123] More API review --- .../MissingTransactionFactory.cs | 1 + .../OperationProcessorAccessor.cs | 7 +- .../Processors/IOperationProcessor.cs | 3 + .../Controllers/BaseJsonApiController.cs | 2 +- .../BaseJsonApiOperationsController.cs | 9 ++- .../Controllers/ModelStateViolation.cs | 3 + ...eIdInCreateResourceNotAllowedException.cs} | 6 +- .../AtomicOperationsResponseSerializer.cs | 66 +++++++++---------- .../Serialization/JsonApiReader.cs | 3 +- .../Serialization/ResponseSerializer.cs | 1 + ...icConstrainedOperationsControllerTests.cs} | 12 ++-- 11 files changed, 64 insertions(+), 49 deletions(-) rename src/JsonApiDotNetCore/Errors/{ResourceIdInCreateResourceRequestNotAllowedException.cs => ResourceIdInCreateResourceNotAllowedException.cs} (76%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/{AtomicCreateMusicTrackTests.cs => AtomicConstrainedOperationsControllerTests.cs} (93%) diff --git a/src/JsonApiDotNetCore/AtomicOperations/MissingTransactionFactory.cs b/src/JsonApiDotNetCore/AtomicOperations/MissingTransactionFactory.cs index b59338fab2..75c327c0f2 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/MissingTransactionFactory.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/MissingTransactionFactory.cs @@ -9,6 +9,7 @@ namespace JsonApiDotNetCore.AtomicOperations /// 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 diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs index 934c9d9ac4..31dea19969 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs @@ -9,6 +9,7 @@ namespace JsonApiDotNetCore.AtomicOperations { + /// public class OperationProcessorAccessor : IOperationProcessorAccessor { private readonly IResourceContextProvider _resourceContextProvider; @@ -17,13 +18,15 @@ public class OperationProcessorAccessor : IOperationProcessorAccessor public OperationProcessorAccessor(IResourceContextProvider resourceContextProvider, IServiceProvider serviceProvider) { - _resourceContextProvider = resourceContextProvider ?? - throw new ArgumentNullException(nameof(resourceContextProvider)); + _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); } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs index 6f49d94443..6b51694260 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs @@ -9,6 +9,9 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors /// public interface IOperationProcessor { + /// + /// Processes the specified operation. + /// Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); } } diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index b13953a4a9..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 ResourceIdInCreateResourceRequestNotAllowedException(); + throw new ResourceIdInCreateResourceNotAllowedException(); if (_options.ValidateModelState && !ModelState.IsValid) { diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs index b0a5956549..ee26a69130 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs @@ -39,8 +39,8 @@ protected BaseJsonApiOperationsController(IJsonApiOptions options, ILoggerFactor /// /// Atomically processes a list of operations and returns a list of results. - /// If processing fails, all changes are reverted. - /// If processing succeeds, but none of the operations returns any data, then HTTP 201 is returned instead of 200. + /// 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. @@ -114,6 +114,9 @@ public virtual async Task PostOperationsAsync([FromBody] IList 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; @@ -161,7 +164,7 @@ protected virtual void ValidateClientGeneratedIds(IEnumerable + /// Represents the violation of a model state validation rule. + /// public sealed class ModelStateViolation { public string Prefix { get; } diff --git a/src/JsonApiDotNetCore/Errors/ResourceIdInCreateResourceRequestNotAllowedException.cs b/src/JsonApiDotNetCore/Errors/ResourceIdInCreateResourceNotAllowedException.cs similarity index 76% rename from src/JsonApiDotNetCore/Errors/ResourceIdInCreateResourceRequestNotAllowedException.cs rename to src/JsonApiDotNetCore/Errors/ResourceIdInCreateResourceNotAllowedException.cs index 182c8f9175..fff90a6fb9 100644 --- a/src/JsonApiDotNetCore/Errors/ResourceIdInCreateResourceRequestNotAllowedException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourceIdInCreateResourceNotAllowedException.cs @@ -4,11 +4,11 @@ namespace JsonApiDotNetCore.Errors { /// - /// The error that is thrown when a resource creation request is received that contains a client-generated ID. + /// The error that is thrown when a resource creation request or operation is received that contains a client-generated ID. /// - public sealed class ResourceIdInCreateResourceRequestNotAllowedException : JsonApiException + public sealed class ResourceIdInCreateResourceNotAllowedException : JsonApiException { - public ResourceIdInCreateResourceRequestNotAllowedException(int? atomicOperationIndex = null) + public ResourceIdInCreateResourceNotAllowedException(int? atomicOperationIndex = null) : base(new Error(HttpStatusCode.Forbidden) { Title = atomicOperationIndex == null diff --git a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs index a656e709b9..ba69bc0be6 100644 --- a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; @@ -9,33 +10,32 @@ namespace JsonApiDotNetCore.Serialization { /// - /// Server serializer implementation of for atomic:operations requests. + /// Server serializer implementation of for atomic:operations responses. /// public sealed class AtomicOperationsResponseSerializer : BaseSerializer, IJsonApiSerializer { + private readonly IMetaBuilder _metaBuilder; private readonly ILinkBuilder _linkBuilder; - private readonly IResourceContextProvider _resourceContextProvider; private readonly IFieldsToSerialize _fieldsToSerialize; private readonly IJsonApiRequest _request; private readonly IJsonApiOptions _options; - private readonly IMetaBuilder _metaBuilder; + /// public string ContentType { get; } = HeaderConstants.AtomicOperationsMediaType; - public AtomicOperationsResponseSerializer(IMetaBuilder metaBuilder, ILinkBuilder linkBuilder, - IResourceObjectBuilder resourceObjectBuilder, IResourceContextProvider resourceContextProvider, - IFieldsToSerialize fieldsToSerialize, IJsonApiRequest request, - IJsonApiOptions options) + 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)); - _resourceContextProvider = resourceContextProvider ?? throw new ArgumentNullException(nameof(resourceContextProvider)); _fieldsToSerialize = fieldsToSerialize ?? throw new ArgumentNullException(nameof(fieldsToSerialize)); _request = request ?? throw new ArgumentNullException(nameof(request)); _options = options ?? throw new ArgumentNullException(nameof(options)); - _metaBuilder = metaBuilder ?? throw new ArgumentNullException(nameof(metaBuilder)); } + /// public string Serialize(object content) { if (content is IList operations) @@ -48,45 +48,45 @@ public string Serialize(object content) return SerializeErrorDocument(errorDocument); } - throw new InvalidOperationException("Data being returned must be errors or an atomic:operations document."); + throw new InvalidOperationException("Data being returned must be errors or operations."); } private string SerializeOperationsDocument(IEnumerable operations) { var document = new AtomicOperationsDocument { - Results = new List() + Results = operations.Select(SerializeOperation).ToList(), + Meta = _metaBuilder.Build() }; - foreach (var operation in operations) - { - ResourceObject resourceObject = null; + return SerializeObject(document, _options.SerializerSettings); + } - if (operation != null) - { - _request.CopyFrom(operation.Request); - _fieldsToSerialize.ResetCache(); + private AtomicResultObject SerializeOperation(OperationContainer operation) + { + ResourceObject resourceObject = null; - var resourceType = operation.Resource.GetType(); - var attributes = _fieldsToSerialize.GetAttributes(resourceType); - var relationships = _fieldsToSerialize.GetRelationships(resourceType); + if (operation != null) + { + _request.CopyFrom(operation.Request); + _fieldsToSerialize.ResetCache(); - resourceObject = ResourceObjectBuilder.Build(operation.Resource, attributes, relationships); - if (resourceObject != null) - { - resourceObject.Links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); - } - } + var resourceType = operation.Resource.GetType(); + var attributes = _fieldsToSerialize.GetAttributes(resourceType); + var relationships = _fieldsToSerialize.GetRelationships(resourceType); - document.Results.Add(new AtomicResultObject - { - Data = resourceObject - }); + resourceObject = ResourceObjectBuilder.Build(operation.Resource, attributes, relationships); } - document.Meta = _metaBuilder.Build(); + if (resourceObject != null) + { + resourceObject.Links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); + } - return SerializeObject(document, _options.SerializerSettings); + return new AtomicResultObject + { + Data = resourceObject + }; } private string SerializeErrorDocument(ErrorDocument errorDocument) diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs index 7413933f85..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); } @@ -93,6 +93,7 @@ private InvalidRequestBodyException ToInvalidRequestBodyException(JsonApiSeriali 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); diff --git a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs index 017e829055..cef0f45efe 100644 --- a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs @@ -32,6 +32,7 @@ public class ResponseSerializer : BaseSerializer, IJsonApiSerializer private readonly ILinkBuilder _linkBuilder; private readonly IIncludedResourceObjectBuilder _includedBuilder; + /// public string ContentType { get; } = HeaderConstants.MediaType; public ResponseSerializer(IMetaBuilder metaBuilder, diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/AtomicCreateMusicTrackTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs similarity index 93% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/AtomicCreateMusicTrackTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs index 378d6e3252..269c7e75f3 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/AtomicCreateMusicTrackTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs @@ -6,13 +6,13 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Controllers { - public sealed class AtomicCreateMusicTrackTests + public sealed class AtomicConstrainedOperationsControllerTests : IClassFixture, OperationsDbContext>> { private readonly IntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicCreateMusicTrackTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicConstrainedOperationsControllerTests(IntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; @@ -20,7 +20,7 @@ public AtomicCreateMusicTrackTests(IntegrationTestContext } [Fact] - public async Task Cannot_add_to_ToMany_relationship() + public async Task Cannot_add_to_ToMany_relationship_for_matching_resource_type() { // Arrange var existingTrack = _fakers.MusicTrack.Generate(); From aa8084ceca84d0b0d6d05c2096961f691e1630f3 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 21 Jan 2021 13:03:42 +0100 Subject: [PATCH 070/123] Cleanup RequestDeserializer --- .../Serialization/RequestDeserializer.cs | 550 ++++++++++-------- .../Creating/AtomicCreateResourceTests.cs | 30 + ...reateResourceWithClientGeneratedIdTests.cs | 40 ++ .../AtomicReplaceToManyRelationshipTests.cs | 106 ++++ .../AtomicUpdateToOneRelationshipTests.cs | 98 ++++ .../Resources/AtomicUpdateResourceTests.cs | 76 +++ 6 files changed, 662 insertions(+), 238 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index bfb3f0f6e1..1dcb81521d 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -18,7 +18,7 @@ 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; @@ -29,7 +29,7 @@ public RequestDeserializer( ITargetedFields targetedFields, IHttpContextAccessor httpContextAccessor, IJsonApiRequest request, - IJsonApiOptions options) + IJsonApiOptions options) : base(resourceContextProvider, resourceFactory) { _targetedFields = targetedFields ?? throw new ArgumentNullException(nameof(targetedFields)); @@ -72,7 +72,7 @@ private object DeserializeOperationsDocument(string body) if (document.Operations.Count > _options.MaximumOperationsPerRequest) { - throw new JsonApiSerializationException("Request exceeds the maximum number of operations.", + 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}."); } @@ -90,330 +90,401 @@ private object DeserializeOperationsDocument(string body) return operations; } - // TODO: Cleanup code. - private OperationContainer DeserializeOperation(AtomicOperationObject operation) { - if (operation.Href != null) + _targetedFields.Attributes.Clear(); + _targetedFields.Relationships.Clear(); + + AssertHasNoHref(operation); + + var kind = GetOperationKind(operation); + switch (kind) { - throw new JsonApiSerializationException("Usage of the 'href' element is not supported.", null, - atomicOperationIndex: AtomicOperationIndex); + case OperationKind.CreateResource: + case OperationKind.UpdateResource: + { + return ParseForCreateOrUpdateResourceOperation(operation, kind); + } + case OperationKind.DeleteResource: + { + return ParseForDeleteResourceOperation(operation, kind); + } } - var kind = GetOperationKind(operation); + bool requireToManyRelationship = + kind == OperationKind.AddToRelationship || kind == OperationKind.RemoveFromRelationship; + + return ParseForRelationshipOperation(operation, kind, requireToManyRelationship); + } - if (kind == OperationKind.AddToRelationship && operation.Ref.Relationship == null) + private void AssertHasNoHref(AtomicOperationObject operation) + { + if (operation.Href != null) { - throw new JsonApiSerializationException("The 'ref.relationship' element is required.", null, + throw new JsonApiSerializationException("Usage of the 'href' element is not supported.", null, atomicOperationIndex: AtomicOperationIndex); } + } - RelationshipAttribute relationshipInRef = null; - ResourceContext resourceContextInRef = null; - string resourceName = null; - - if (operation.Ref != null) + private OperationKind GetOperationKind(AtomicOperationObject operation) + { + switch (operation.Code) { - if (operation.Ref.Type == null) + case AtomicOperationCode.Add: { - throw new JsonApiSerializationException("The 'ref.type' element is required.", null, - atomicOperationIndex: AtomicOperationIndex); - } - - resourceContextInRef = GetExistingResourceContext(operation.Ref.Type); - resourceName = resourceContextInRef.PublicName; - - bool hasNone = operation.Ref.Id == null && operation.Ref.Lid == null; - bool hasBoth = operation.Ref.Id != null && operation.Ref.Lid != null; + if (operation.Ref != null && operation.Ref.Relationship == null) + { + throw new JsonApiSerializationException("The 'ref.relationship' element is required.", null, + atomicOperationIndex: AtomicOperationIndex); + } - if (hasNone || hasBoth) - { - throw new JsonApiSerializationException("The 'ref.id' or 'ref.lid' element is required.", null, - atomicOperationIndex: AtomicOperationIndex); + return operation.Ref == null ? OperationKind.CreateResource : OperationKind.AddToRelationship; } - - if (operation.Ref.Id != null) + case AtomicOperationCode.Update: { - try - { - TypeHelper.ConvertType(operation.Ref.Id, resourceContextInRef.IdentityType); - } - catch (FormatException exception) - { - throw new JsonApiSerializationException(null, exception.Message, null, AtomicOperationIndex); - } + return operation.Ref?.Relationship != null + ? OperationKind.SetRelationship + : OperationKind.UpdateResource; } - - if (operation.Ref.Relationship != null) + case AtomicOperationCode.Remove: { - relationshipInRef = resourceContextInRef.Relationships.FirstOrDefault(r => r.PublicName == operation.Ref.Relationship); - if (relationshipInRef == null) - { - throw new JsonApiSerializationException( - "The referenced relationship does not exist.", - $"Resource of type '{operation.Ref.Type}' does not contain a relationship named '{operation.Ref.Relationship}'.", - atomicOperationIndex: AtomicOperationIndex); - } - - if ((kind == OperationKind.AddToRelationship || kind == OperationKind.RemoveFromRelationship) && relationshipInRef 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); - } - - if (relationshipInRef is HasOneAttribute && operation.ManyData != null) + if (operation.Ref == null) { - throw new JsonApiSerializationException( - "Expected single data element for to-one relationship.", - $"Expected single data element for '{relationshipInRef.PublicName}' relationship.", + throw new JsonApiSerializationException("The 'ref' element is required.", null, atomicOperationIndex: AtomicOperationIndex); } - if (relationshipInRef is HasManyAttribute && operation.ManyData == null) - { - throw new JsonApiSerializationException( - "Expected data[] element for to-many relationship.", - $"Expected data[] element for '{relationshipInRef.PublicName}' relationship.", - atomicOperationIndex: AtomicOperationIndex); - } + return operation.Ref.Relationship != null + ? OperationKind.RemoveFromRelationship + : OperationKind.DeleteResource; } } - if (operation.ManyData != null) - { - foreach (var resourceObject in operation.ManyData) - { - if (relationshipInRef == null) - { - throw new JsonApiSerializationException("Expected single data element for create/update resource operation.", - null, atomicOperationIndex: AtomicOperationIndex); - } + throw new NotSupportedException($"Unknown operation code '{operation.Code}'."); + } - if (resourceObject.Type == null) - { - throw new JsonApiSerializationException("The 'data[].type' element is required.", null, - atomicOperationIndex: AtomicOperationIndex); - } + private OperationContainer ParseForCreateOrUpdateResourceOperation(AtomicOperationObject operation, + OperationKind kind) + { + var resourceObject = GetRequiredSingleDataForResourceOperation(operation); - bool hasNone = resourceObject.Id == null && resourceObject.Lid == null; - bool hasBoth = resourceObject.Id != null && resourceObject.Lid != null; + AssertElementHasType(resourceObject, "data"); + AssertElementHasIdOrLid(resourceObject, "data", kind != OperationKind.CreateResource); - if (hasNone || hasBoth) - { - throw new JsonApiSerializationException("The 'data[].id' or 'data[].lid' element is required.", null, - atomicOperationIndex: AtomicOperationIndex); - } + var primaryResourceContext = GetExistingResourceContext(resourceObject.Type); - var rightResourceContext = GetExistingResourceContext(resourceObject.Type); - if (!rightResourceContext.ResourceType.IsAssignableFrom(relationshipInRef.RightType)) - { - var relationshipRightTypeName = ResourceContextProvider.GetResourceContext(relationshipInRef.RightType); - - throw new JsonApiSerializationException("Resource type mismatch between 'ref.relationship' and 'data[].type' element.", - $@"Expected resource of type '{relationshipRightTypeName}' in 'data[].type', instead of '{rightResourceContext.PublicName}'.", - atomicOperationIndex: AtomicOperationIndex); - } - } - } + AssertCompatibleId(resourceObject, primaryResourceContext.IdentityType); - if (operation.SingleData != null) + if (operation.Ref != null) { - var resourceObject = operation.SingleData; + // For resource update, 'ref' is optional. But when specified, it must match with 'data'. - if (resourceObject.Type == null) - { - throw new JsonApiSerializationException("The 'data.type' element is required.", null, - atomicOperationIndex: AtomicOperationIndex); - } + AssertElementHasType(operation.Ref, "ref"); + AssertElementHasIdOrLid(operation.Ref, "ref", true); - bool hasNone = resourceObject.Id == null && resourceObject.Lid == null; - bool hasBoth = resourceObject.Id != null && resourceObject.Lid != null; + var resourceContextInRef = GetExistingResourceContext(operation.Ref.Type); - if (kind == OperationKind.CreateResource ? hasBoth : hasNone || hasBoth) + if (resourceContextInRef != primaryResourceContext) { - throw new JsonApiSerializationException("The 'data.id' or 'data.lid' element is required.", null, + 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); } - var resourceContextInData = GetExistingResourceContext(resourceObject.Type); - resourceName ??= resourceContextInData.PublicName; + AssertSameIdentityInRefData(operation, resourceObject); + } - if (kind.IsRelationship() && relationshipInRef != null) - { - var rightResourceContext = resourceContextInData; - if (!rightResourceContext.ResourceType.IsAssignableFrom(relationshipInRef.RightType)) - { - var relationshipRightTypeName = ResourceContextProvider.GetResourceContext(relationshipInRef.RightType); - - throw new JsonApiSerializationException("Resource type mismatch between 'ref.relationship' and 'data.type' element.", - $@"Expected resource of type '{relationshipRightTypeName}' in 'data.type', instead of '{rightResourceContext.PublicName}'.", - atomicOperationIndex: AtomicOperationIndex); - } - } - else - { - if (resourceContextInRef != null && resourceContextInRef != resourceContextInData) - { - throw new JsonApiSerializationException("Resource type mismatch between 'ref.type' and 'data.type' element.", - $@"Expected resource of type '{resourceContextInRef.PublicName}' in 'data.type', instead of '{resourceContextInData.PublicName}'.", - atomicOperationIndex: AtomicOperationIndex); - } + var request = new JsonApiRequest + { + Kind = EndpointKind.AtomicOperations, + BasePath = _request.BasePath, + PrimaryResource = primaryResourceContext, + OperationKind = kind + }; + _request.CopyFrom(request); - if (operation.Ref != null) - { - if (operation.Ref.Id != null && resourceObject.Id != null && resourceObject.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 '{resourceObject.Id}'.", - atomicOperationIndex: AtomicOperationIndex); - } - - if (operation.Ref.Lid != null && resourceObject.Lid != null && resourceObject.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 '{resourceObject.Lid}'.", - atomicOperationIndex: AtomicOperationIndex); - } - - if (operation.Ref.Id != null && resourceObject.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 '{resourceObject.Lid}' in 'data.lid'.", - atomicOperationIndex: AtomicOperationIndex); - } - - if (operation.Ref.Lid != null && resourceObject.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 '{resourceObject.Id}' in 'data.id'.", - atomicOperationIndex: AtomicOperationIndex); - } - } - } - } + var primaryResource = ParseResourceObject(operation.SingleData); + + request.PrimaryId = primaryResource.StringId; + _request.CopyFrom(request); - return ToOperationContainer(operation, resourceName, kind); + var targetedFields = new TargetedFields + { + Attributes = _targetedFields.Attributes.ToHashSet(), + Relationships = _targetedFields.Relationships.ToHashSet() + }; + + AssertResourceIdIsNotTargeted(targetedFields); + + return new OperationContainer(kind, primaryResource, targetedFields, request); } - private OperationContainer ToOperationContainer(AtomicOperationObject operation, string resourceName, OperationKind kind) + private ResourceObject GetRequiredSingleDataForResourceOperation(AtomicOperationObject operation) { - var primaryResourceContext = GetExistingResourceContext(resourceName); + if (operation.Data == null) + { + throw new JsonApiSerializationException("The 'data' element is required.", null, + atomicOperationIndex: AtomicOperationIndex); + } - _targetedFields.Attributes.Clear(); - _targetedFields.Relationships.Clear(); + if (operation.SingleData == null) + { + throw new JsonApiSerializationException( + "Expected single data element for create/update resource operation.", + null, atomicOperationIndex: AtomicOperationIndex); + } - IIdentifiable resource; + return operation.SingleData; + } - switch (kind) + private void AssertElementHasType(ResourceIdentifierObject resourceIdentifierObject, string elementPath) + { + if (resourceIdentifierObject.Type == null) { - case OperationKind.CreateResource: - case OperationKind.UpdateResource: - { - // TODO: @OPS: Chicken-and-egg problem: ParseResourceObject depends on _request.OperationKind, which is not fully built yet. - ((JsonApiRequest) _request).OperationKind = kind; + throw new JsonApiSerializationException($"The '{elementPath}.type' element is required.", null, + atomicOperationIndex: AtomicOperationIndex); + } + } - resource = ParseResourceObject(operation.SingleData); - break; - } - case OperationKind.DeleteResource: - case OperationKind.SetRelationship: - case OperationKind.AddToRelationship: - case OperationKind.RemoveFromRelationship: + 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 { - resource = ResourceFactory.CreateInstance(primaryResourceContext.ResourceType); - resource.StringId = operation.Ref.Id; - resource.LocalId = operation.Ref.Lid; - break; + TypeHelper.ConvertType(resourceIdentifierObject.Id, idType); } - default: + catch (FormatException exception) { - throw new NotSupportedException($"Unknown operation kind '{kind}'."); + 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, - OperationKind = kind, - PrimaryId = resource.StringId, BasePath = _request.BasePath, - PrimaryResource = primaryResourceContext + PrimaryId = primaryResource.StringId, + PrimaryResource = primaryResourceContext, + OperationKind = kind }; - if (operation.Ref?.Relationship != null) - { - var relationship = primaryResourceContext.Relationships.Single(r => r.PublicName == operation.Ref.Relationship); + return new OperationContainer(kind, primaryResource, new TargetedFields(), request); + } - var secondaryResourceContext = ResourceContextProvider.GetResourceContext(relationship.RightType); - if (secondaryResourceContext == null) - { - throw new InvalidOperationException("TODO: @OPS: Secondary resource type does not exist."); - } + private OperationContainer ParseForRelationshipOperation(AtomicOperationObject operation, OperationKind kind, + bool requireToMany) + { + AssertElementHasType(operation.Ref, "ref"); + AssertElementHasIdOrLid(operation.Ref, "ref", true); - request.SecondaryResource = secondaryResourceContext; - request.Relationship = relationship; - request.IsCollection = relationship is HasManyAttribute; + var primaryResourceContext = GetExistingResourceContext(operation.Ref.Type); - _targetedFields.Relationships.Add(relationship); + AssertCompatibleId(operation.Ref, primaryResourceContext.IdentityType); - if (operation.SingleData != null) - { - var rightResource = ParseResourceObject(operation.SingleData); - relationship.SetValue(resource, rightResource); - } - else if (operation.ManyData != null) - { - var secondaryResources = operation.ManyData.Select(ParseResourceObject).ToArray(); - var rightResources = TypeHelper.CopyToTypedCollection(secondaryResources, relationship.Property.PropertyType); - relationship.SetValue(resource, rightResources); - } + 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() }; - AssertResourceIdIsNotTargeted(targetedFields); + return new OperationContainer(kind, primaryResource, targetedFields, request); + } + + private RelationshipAttribute GetExistingRelationship(AtomicReference reference, + ResourceContext resourceContext) + { + var relationship = resourceContext.Relationships.FirstOrDefault(attribute => + attribute.PublicName == reference.Relationship); - return new OperationContainer(kind, resource, targetedFields, request); + 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 OperationKind GetOperationKind(AtomicOperationObject operation) + private void ParseDataForRelationship(RelationshipAttribute relationship, + ResourceContext secondaryResourceContext, + AtomicOperationObject operation, IIdentifiable primaryResource) { - switch (operation.Code) + if (relationship is HasOneAttribute) { - case AtomicOperationCode.Add: + if (operation.ManyData != null) { - return operation.Ref != null ? OperationKind.AddToRelationship : OperationKind.CreateResource; + throw new JsonApiSerializationException( + "Expected single data element for to-one relationship.", + $"Expected single data element for '{relationship.PublicName}' relationship.", + atomicOperationIndex: AtomicOperationIndex); } - case AtomicOperationCode.Update: + + if (operation.SingleData != null) { - return operation.Ref?.Relationship != null ? OperationKind.SetRelationship : OperationKind.UpdateResource; + ValidateDataForRelationship(operation.SingleData, secondaryResourceContext, "data"); + + var secondaryResource = ParseResourceObject(operation.SingleData); + relationship.SetValue(primaryResource, secondaryResource); } - 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; + 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); } - default: + + List secondaryResources = new List(); + + foreach (var resourceObject in operation.ManyData) { - throw new NotSupportedException($"Unknown operation code '{operation.Code}'."); + ValidateDataForRelationship(resourceObject, secondaryResourceContext, "data[]"); + + var secondaryResource = ParseResourceObject(resourceObject); + secondaryResources.Add(secondaryResource); } + + var rightResources = + TypeHelper.CopyToTypedCollection(secondaryResources, relationship.Property.PropertyType); + relationship.SetValue(primaryResource, rightResources); + } + } + + private void ValidateDataForRelationship(ResourceObject dataResourceObject, + ResourceContext secondaryResourceContext, string elementPath) + { + AssertElementHasType(dataResourceObject, elementPath); + AssertElementHasIdOrLid(dataResourceObject, elementPath, true); + + var resourceContextInData = GetExistingResourceContext(dataResourceObject.Type); + + AssertCompatibleType(resourceContextInData, secondaryResourceContext, 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))) + if (!_request.IsReadOnly && + targetedFields.Attributes.Any(attribute => attribute.Property.Name == nameof(Identifiable.Id))) { throw new JsonApiSerializationException("Resource ID is read-only.", null, atomicOperationIndex: AtomicOperationIndex); @@ -427,7 +498,8 @@ private void AssertResourceIdIsNotTargeted(ITargetedFields targetedFields) /// 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(); @@ -453,7 +525,9 @@ protected override void AfterProcessField(IIdentifiable resource, ResourceFieldA _targetedFields.Attributes.Add(attr); } else if (field is RelationshipAttribute relationship) + { _targetedFields.Relationships.Add(relationship); + } } private bool IsCreatingResource() diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index f38b87e8c5..e345d07a89 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -418,6 +418,36 @@ public async Task Cannot_create_resource_for_ref_element() 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() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs index 358365a945..d792fe548c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -177,6 +177,46 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs index 1ebd3bba79..4605666aa7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs @@ -483,6 +483,60 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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() { @@ -840,6 +894,58 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs index dc2e96acbb..3cec2ec60a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs @@ -722,6 +722,55 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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() { @@ -1059,6 +1108,55 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs index e6ab220d19..6ad5e30278 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Net; @@ -705,6 +706,36 @@ public async Task Cannot_update_resource_for_ID_and_local_ID_in_ref() 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() { @@ -1189,6 +1220,51 @@ public async Task Cannot_update_resource_for_unknown_ID() 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() { From 79548828feb7a1b0df3bdc6cdff0749ab7c46432 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 22 Jan 2021 11:09:31 +0100 Subject: [PATCH 071/123] Small name tweaks --- .../Serialization/RequestDeserializer.cs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index 1dcb81521d..e942081d2c 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -424,14 +424,13 @@ private void ParseDataForRelationship(RelationshipAttribute relationship, if (operation.SingleData != null) { - ValidateDataForRelationship(operation.SingleData, secondaryResourceContext, "data"); + ValidateSingleDataForRelationship(operation.SingleData, secondaryResourceContext, "data"); var secondaryResource = ParseResourceObject(operation.SingleData); relationship.SetValue(primaryResource, secondaryResource); } } - - if (relationship is HasManyAttribute) + else if (relationship is HasManyAttribute) { if (operation.ManyData == null) { @@ -441,11 +440,11 @@ private void ParseDataForRelationship(RelationshipAttribute relationship, atomicOperationIndex: AtomicOperationIndex); } - List secondaryResources = new List(); + var secondaryResources = new List(); foreach (var resourceObject in operation.ManyData) { - ValidateDataForRelationship(resourceObject, secondaryResourceContext, "data[]"); + ValidateSingleDataForRelationship(resourceObject, secondaryResourceContext, "data[]"); var secondaryResource = ParseResourceObject(resourceObject); secondaryResources.Add(secondaryResource); @@ -457,15 +456,15 @@ private void ParseDataForRelationship(RelationshipAttribute relationship, } } - private void ValidateDataForRelationship(ResourceObject dataResourceObject, - ResourceContext secondaryResourceContext, string elementPath) + private void ValidateSingleDataForRelationship(ResourceObject dataResourceObject, + ResourceContext resourceContext, string elementPath) { AssertElementHasType(dataResourceObject, elementPath); AssertElementHasIdOrLid(dataResourceObject, elementPath, true); var resourceContextInData = GetExistingResourceContext(dataResourceObject.Type); - AssertCompatibleType(resourceContextInData, secondaryResourceContext, elementPath); + AssertCompatibleType(resourceContextInData, resourceContext, elementPath); AssertCompatibleId(dataResourceObject, resourceContextInData.IdentityType); } From 3f3667c6352d10bbb443787ef5da39b63a74dd21 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 22 Jan 2021 11:35:26 +0100 Subject: [PATCH 072/123] API shape tweaks --- .../EntityFrameworkCoreTransaction.cs | 9 ++++++++- .../AtomicOperations/IOperationsTransaction.cs | 9 +++++++-- .../AtomicOperations/OperationsProcessor.cs | 16 ++++++++++------ .../Processors/AddToRelationshipProcessor.cs | 3 ++- .../Processors/CreateProcessor.cs | 2 +- .../Processors/DeleteProcessor.cs | 2 +- .../RemoveFromRelationshipProcessor.cs | 2 +- .../Processors/SetRelationshipProcessor.cs | 2 +- .../Processors/UpdateProcessor.cs | 3 ++- ...ption.cs => NonSharedTransactionException.cs} | 4 ++-- .../Middleware/ExceptionHandler.cs | 2 +- .../Repositories/ResourceRepositoryAccessor.cs | 2 +- 12 files changed, 37 insertions(+), 19 deletions(-) rename src/JsonApiDotNetCore/Errors/{MultipleActiveTransactionsException.cs => NonSharedTransactionException.cs} (83%) diff --git a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs index 7299d2d3ac..acf23f1087 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs @@ -27,11 +27,18 @@ public EntityFrameworkCoreTransaction(IDbContextTransaction transaction, DbConte /// /// Detaches all entities from the Entity Framework Core change tracker. /// - public void PrepareForNextOperation() + public void BeforeProcessOperation() { _dbContext.ResetChangeTracker(); } + /// + /// Does nothing. + /// + public void AfterProcessOperation() + { + } + /// public Task CommitAsync(CancellationToken cancellationToken) { diff --git a/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransaction.cs b/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransaction.cs index 337a79cf4c..81f43182b3 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransaction.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransaction.cs @@ -15,9 +15,14 @@ public interface IOperationsTransaction : IAsyncDisposable Guid TransactionId { get; } /// - /// Enables to execute custom logic before processing of the next operation starts. + /// Enables to execute custom logic before processing of an operation starts. /// - void PrepareForNextOperation(); + void BeforeProcessOperation(); + + /// + /// Enables to execute custom logic after processing of an operation succeeds. + /// + void AfterProcessOperation(); /// /// Commits all changes made to the underlying data store. diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs index 827f22ab12..6e88f831e5 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs @@ -34,7 +34,8 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso } /// - public async Task> ProcessAsync(IList operations, CancellationToken cancellationToken) + public virtual async Task> ProcessAsync(IList operations, + CancellationToken cancellationToken) { if (operations == null) throw new ArgumentNullException(nameof(operations)); @@ -49,10 +50,12 @@ public async Task> ProcessAsync(IList> ProcessAsync(IList ProcessOperation(OperationContainer operation, CancellationToken cancellationToken) + protected virtual async Task ProcessOperation(OperationContainer operation, + CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -94,13 +98,13 @@ private async Task ProcessOperation(OperationContainer opera _targetedFields.Attributes = operation.TargetedFields.Attributes; _targetedFields.Relationships = operation.TargetedFields.Relationships; - + _request.CopyFrom(operation.Request); - + return await _operationProcessorAccessor.ProcessAsync(operation, cancellationToken); } - private void TrackLocalIds(OperationContainer operation) + protected void TrackLocalIds(OperationContainer operation) { if (operation.Kind == OperationKind.CreateResource) { diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs index 9ca044f984..63f16c21ab 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs @@ -18,7 +18,8 @@ public AddToRelationshipProcessor(IAddToRelationshipService serv } /// - public async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public virtual async Task ProcessAsync(OperationContainer operation, + CancellationToken cancellationToken) { if (operation == null) throw new ArgumentNullException(nameof(operation)); diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs index 917422ca8b..062e096910 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs @@ -24,7 +24,7 @@ public CreateProcessor(ICreateService service, ILocalIdTracker l } /// - public async Task ProcessAsync(OperationContainer operation, + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { if (operation == null) throw new ArgumentNullException(nameof(operation)); diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs index aba81211f0..dea9ba72da 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs @@ -18,7 +18,7 @@ public DeleteProcessor(IDeleteService service) } /// - public async Task ProcessAsync(OperationContainer operation, + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { if (operation == null) throw new ArgumentNullException(nameof(operation)); diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs index cdbfb4bb45..f87373783a 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs @@ -18,7 +18,7 @@ public RemoveFromRelationshipProcessor(IRemoveFromRelationshipService - public async Task ProcessAsync(OperationContainer operation, + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { if (operation == null) throw new ArgumentNullException(nameof(operation)); diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs index 393ecb2ed1..8e9c27ff91 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs @@ -18,7 +18,7 @@ public SetRelationshipProcessor(ISetRelationshipService service) } /// - public async Task ProcessAsync(OperationContainer operation, + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { if (operation == null) throw new ArgumentNullException(nameof(operation)); diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs index 5350ea0fb5..25f3232ffc 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs @@ -18,7 +18,8 @@ public UpdateProcessor(IUpdateService service) } /// - public async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + public virtual async Task ProcessAsync(OperationContainer operation, + CancellationToken cancellationToken) { if (operation == null) throw new ArgumentNullException(nameof(operation)); diff --git a/src/JsonApiDotNetCore/Errors/MultipleActiveTransactionsException.cs b/src/JsonApiDotNetCore/Errors/NonSharedTransactionException.cs similarity index 83% rename from src/JsonApiDotNetCore/Errors/MultipleActiveTransactionsException.cs rename to src/JsonApiDotNetCore/Errors/NonSharedTransactionException.cs index cc2deb9f01..d0bcd69505 100644 --- a/src/JsonApiDotNetCore/Errors/MultipleActiveTransactionsException.cs +++ b/src/JsonApiDotNetCore/Errors/NonSharedTransactionException.cs @@ -7,9 +7,9 @@ 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 MultipleActiveTransactionsException : JsonApiException + public sealed class NonSharedTransactionException : JsonApiException { - public MultipleActiveTransactionsException() + public NonSharedTransactionException() : base(new Error(HttpStatusCode.UnprocessableEntity) { Title = "Unsupported combination of resource types in atomic:operations request.", diff --git a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs index db270ec56d..25b77b734a 100644 --- a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs @@ -72,7 +72,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/Repositories/ResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs index cf14cfb704..cc7af76e18 100644 --- a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs @@ -148,7 +148,7 @@ private object GetWriteRepository(Type resourceType) if (repository.TransactionId != _request.TransactionId) { - throw new MultipleActiveTransactionsException(); + throw new NonSharedTransactionException(); } } From 42c1ede691b8f51d3e3ccfc5c6347c062067457f Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 22 Jan 2021 15:05:34 +0100 Subject: [PATCH 073/123] Validate local ID usage upfront --- .../AtomicOperations/ILocalIdTracker.cs | 5 + .../AtomicOperations/LocalIdTracker.cs | 6 + .../AtomicOperations/LocalIdValidator.cs | 97 ++++++++++++ .../OperationContainerExtensions.cs | 38 ----- .../AtomicOperations/OperationsProcessor.cs | 21 ++- .../Processors/AddToRelationshipProcessor.cs | 2 +- .../RemoveFromRelationshipProcessor.cs | 2 +- .../Processors/SetRelationshipProcessor.cs | 22 ++- .../Resources/OperationContainer.cs | 17 +++ .../LocalIds/AtomicLocalIdTests.cs | 143 ++++++++++++++++-- 10 files changed, 286 insertions(+), 67 deletions(-) create mode 100644 src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs delete mode 100644 src/JsonApiDotNetCore/AtomicOperations/OperationContainerExtensions.cs diff --git a/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs index 97c661bf2a..36c74090fe 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs @@ -5,6 +5,11 @@ namespace JsonApiDotNetCore.AtomicOperations /// public interface ILocalIdTracker { + /// + /// Removes all declared and assigned values. + /// + void Reset(); + /// /// Declares a local ID without assigning a server-generated value. /// diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs index d4737780e5..ae475027c5 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs @@ -11,6 +11,12 @@ public sealed class LocalIdTracker : ILocalIdTracker { private readonly IDictionary _idsTracked = new Dictionary(); + /// + public void Reset() + { + _idsTracked.Clear(); + } + /// public void Declare(string localId, string resourceType) { diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs new file mode 100644 index 0000000000..2c58730fb5 --- /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 with in 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/OperationContainerExtensions.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationContainerExtensions.cs deleted file mode 100644 index 116e0fc406..0000000000 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationContainerExtensions.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.AtomicOperations -{ - public static class OperationContainerExtensions - { - public static ISet GetSecondaryResourceIds(this OperationContainer operation) - { - if (operation == null) throw new ArgumentNullException(nameof(operation)); - - var relationship = operation.Request.Relationship; - var rightValue = relationship.GetValue(operation.Resource); - - var rightResources = TypeHelper.ExtractResources(rightValue); - return rightResources.ToHashSet(IdentifiableComparer.Instance); - } - - public static object GetSecondaryResourceIdOrIds(this OperationContainer operation) - { - if (operation == null) throw new ArgumentNullException(nameof(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/OperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs index 6e88f831e5..873de53664 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs @@ -20,6 +20,7 @@ public class OperationsProcessor : IOperationsProcessor private readonly IResourceContextProvider _resourceContextProvider; private readonly IJsonApiRequest _request; private readonly ITargetedFields _targetedFields; + private readonly LocalIdValidator _localIdValidator; public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccessor, IOperationsTransactionFactory operationsTransactionFactory, ILocalIdTracker localIdTracker, @@ -31,6 +32,7 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso _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); } /// @@ -39,7 +41,8 @@ public virtual async Task> ProcessAsync(IList(); @@ -59,7 +62,6 @@ public virtual async Task> ProcessAsync(IList> ProcessAsync(IList ProcessOperation(OperationContainer operation, @@ -94,7 +98,7 @@ protected virtual async Task ProcessOperation(OperationConta { cancellationToken.ThrowIfCancellationRequested(); - TrackLocalIds(operation); + TrackLocalIdsForOperation(operation); _targetedFields.Attributes = operation.TargetedFields.Attributes; _targetedFields.Relationships = operation.TargetedFields.Relationships; @@ -104,7 +108,7 @@ protected virtual async Task ProcessOperation(OperationConta return await _operationProcessorAccessor.ProcessAsync(operation, cancellationToken); } - protected void TrackLocalIds(OperationContainer operation) + protected void TrackLocalIdsForOperation(OperationContainer operation) { if (operation.Kind == OperationKind.CreateResource) { @@ -115,14 +119,9 @@ protected void TrackLocalIds(OperationContainer operation) AssignStringId(operation.Resource); } - foreach (var relationship in operation.TargetedFields.Relationships) + foreach (var secondaryResource in operation.GetSecondaryResources()) { - var rightValue = relationship.GetValue(operation.Resource); - - foreach (var rightResource in TypeHelper.ExtractResources(rightValue)) - { - AssignStringId(rightResource); - } + AssignStringId(secondaryResource); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs index 63f16c21ab..8aeba25d8f 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs @@ -24,7 +24,7 @@ public virtual async Task ProcessAsync(OperationContainer op if (operation == null) throw new ArgumentNullException(nameof(operation)); var primaryId = (TId) operation.Resource.GetTypedId(); - var secondaryResourceIds = operation.GetSecondaryResourceIds(); + var secondaryResourceIds = operation.GetSecondaryResources(); await _service.AddToToManyRelationshipAsync(primaryId, operation.Request.Relationship.PublicName, secondaryResourceIds, cancellationToken); diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs index f87373783a..2ed9152d93 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs @@ -24,7 +24,7 @@ public virtual async Task ProcessAsync(OperationContainer op if (operation == null) throw new ArgumentNullException(nameof(operation)); var primaryId = (TId) operation.Resource.GetTypedId(); - var secondaryResourceIds = operation.GetSecondaryResourceIds(); + var secondaryResourceIds = operation.GetSecondaryResources(); await _service.RemoveFromToManyRelationshipAsync(primaryId, operation.Request.Relationship.PublicName, secondaryResourceIds, cancellationToken); diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs index 8e9c27ff91..979f44237b 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs @@ -1,7 +1,9 @@ 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 @@ -24,12 +26,26 @@ public virtual async Task ProcessAsync(OperationContainer op if (operation == null) throw new ArgumentNullException(nameof(operation)); var primaryId = (TId) operation.Resource.GetTypedId(); - object secondaryResourceIds = operation.GetSecondaryResourceIdOrIds(); + object rightValue = GetRelationshipRightValue(operation); - await _service.SetRelationshipAsync(primaryId, operation.Request.Relationship.PublicName, - secondaryResourceIds, cancellationToken); + 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/Resources/OperationContainer.cs b/src/JsonApiDotNetCore/Resources/OperationContainer.cs index 6ea3ac5c82..ff37241666 100644 --- a/src/JsonApiDotNetCore/Resources/OperationContainer.cs +++ b/src/JsonApiDotNetCore/Resources/OperationContainer.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using JsonApiDotNetCore.Middleware; namespace JsonApiDotNetCore.Resources @@ -33,5 +34,21 @@ public OperationContainer WithResource(IIdentifiable 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/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs index 016d114e98..64c03d3290 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs @@ -311,6 +311,15 @@ public async Task Cannot_consume_local_ID_that_is_assigned_in_same_operation() { atomic__operations = new object[] { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, new { op = "add", @@ -346,7 +355,7 @@ public async Task Cannot_consume_local_ID_that_is_assigned_in_same_operation() 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[0]"); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[1]"); } [Fact] @@ -360,6 +369,15 @@ public async Task Cannot_reassign_local_ID() { atomic__operations = new object[] { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, new { op = "add", @@ -401,7 +419,7 @@ public async Task Cannot_reassign_local_ID() 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[1]"); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[2]"); } [Fact] @@ -1790,6 +1808,15 @@ public async Task Cannot_consume_unassigned_local_ID_in_ref() { atomic__operations = new object[] { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, new { op = "remove", @@ -1814,7 +1841,7 @@ public async Task Cannot_consume_unassigned_local_ID_in_ref() 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[0]"); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[1]"); } [Fact] @@ -1825,6 +1852,15 @@ public async Task Cannot_consume_unassigned_local_ID_in_data_element() { atomic__operations = new object[] { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, new { op = "update", @@ -1852,7 +1888,7 @@ public async Task Cannot_consume_unassigned_local_ID_in_data_element() 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[0]"); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[1]"); } [Fact] @@ -1871,6 +1907,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { atomic__operations = new object[] { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, new { op = "add", @@ -1904,7 +1949,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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[0]"); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[1]"); } [Fact] @@ -1915,6 +1960,15 @@ public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_elemen { atomic__operations = new object[] { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, new { op = "add", @@ -1949,7 +2003,7 @@ public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_elemen 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[0]"); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[1]"); } [Fact] @@ -1960,6 +2014,15 @@ public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_array( { atomic__operations = new object[] { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, new { op = "add", @@ -1997,7 +2060,7 @@ public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_array( 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[0]"); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[1]"); } [Fact] @@ -2010,6 +2073,15 @@ public async Task Cannot_consume_local_ID_of_different_type_in_same_operation() { atomic__operations = new object[] { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, new { op = "add", @@ -2045,7 +2117,7 @@ public async Task Cannot_consume_local_ID_of_different_type_in_same_operation() 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[0]"); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[1]"); } [Fact] @@ -2058,6 +2130,15 @@ public async Task Cannot_consume_local_ID_of_different_type_in_ref() { atomic__operations = new object[] { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, new { op = "add", @@ -2091,7 +2172,7 @@ public async Task Cannot_consume_local_ID_of_different_type_in_ref() 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[1]"); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[2]"); } [Fact] @@ -2104,6 +2185,15 @@ public async Task Cannot_consume_local_ID_of_different_type_in_data_element() { atomic__operations = new object[] { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, new { op = "add", @@ -2140,7 +2230,7 @@ public async Task Cannot_consume_local_ID_of_different_type_in_data_element() 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[1]"); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[2]"); } [Fact] @@ -2161,6 +2251,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { atomic__operations = new object[] { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, new { op = "add", @@ -2203,7 +2302,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => 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[1]"); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[2]"); } [Fact] @@ -2218,6 +2317,15 @@ public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data { atomic__operations = new object[] { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, new { op = "add", @@ -2265,7 +2373,7 @@ public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data 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[1]"); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[2]"); } [Fact] @@ -2278,6 +2386,15 @@ public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data { atomic__operations = new object[] { + new + { + op = "remove", + @ref = new + { + type = "lyrics", + id = 99999999 + } + }, new { op = "add", @@ -2324,7 +2441,7 @@ public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data 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[1]"); + responseDocument.Errors[0].Source.Pointer.Should().Be("/atomic:operations[2]"); } } } From f451fc79a1218ed64a30eeba90f52fc76adc189b Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 22 Jan 2021 16:20:23 +0100 Subject: [PATCH 074/123] Added documentation --- docs/usage/toc.md | 1 + docs/usage/writing/bulk-batch-operations.md | 116 ++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 docs/usage/writing/bulk-batch-operations.md 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. From f91b25b7932f6e7bda5a5fca4549e9ebaed15153 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Sat, 23 Jan 2021 01:02:34 +0100 Subject: [PATCH 075/123] Small tweaks --- .../EntityFrameworkCoreTransaction.cs | 6 ++-- .../AtomicOperations/ILocalIdTracker.cs | 2 +- .../IOperationsTransaction.cs | 4 +-- .../AtomicOperations/LocalIdValidator.cs | 2 +- .../AtomicOperations/OperationsProcessor.cs | 4 +-- .../BaseJsonApiOperationsController.cs | 34 +++++++++---------- .../Middleware/OperationKind.cs | 3 +- .../Middleware/OperationKindExtensions.cs | 16 --------- 8 files changed, 28 insertions(+), 43 deletions(-) delete mode 100644 src/JsonApiDotNetCore/Middleware/OperationKindExtensions.cs diff --git a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs index acf23f1087..f863a03d1e 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs @@ -27,16 +27,18 @@ public EntityFrameworkCoreTransaction(IDbContextTransaction transaction, DbConte /// /// Detaches all entities from the Entity Framework Core change tracker. /// - public void BeforeProcessOperation() + public Task BeforeProcessOperationAsync(CancellationToken cancellationToken) { _dbContext.ResetChangeTracker(); + return Task.CompletedTask; } /// /// Does nothing. /// - public void AfterProcessOperation() + public Task AfterProcessOperationAsync(CancellationToken cancellationToken) { + return Task.CompletedTask; } /// diff --git a/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs index 36c74090fe..eb61e41371 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs @@ -1,7 +1,7 @@ namespace JsonApiDotNetCore.AtomicOperations { /// - /// Used to track assignments and references to local IDs an in atomic:operations request. + /// Used to track declarations, assignments and references to local IDs an in atomic:operations request. /// public interface ILocalIdTracker { diff --git a/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransaction.cs b/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransaction.cs index 81f43182b3..5e36b5ff03 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransaction.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransaction.cs @@ -17,12 +17,12 @@ public interface IOperationsTransaction : IAsyncDisposable /// /// Enables to execute custom logic before processing of an operation starts. /// - void BeforeProcessOperation(); + Task BeforeProcessOperationAsync(CancellationToken cancellationToken); /// /// Enables to execute custom logic after processing of an operation succeeds. /// - void AfterProcessOperation(); + Task AfterProcessOperationAsync(CancellationToken cancellationToken); /// /// Commits all changes made to the underlying data store. diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs index 2c58730fb5..c5e2a09f03 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs @@ -8,7 +8,7 @@ namespace JsonApiDotNetCore.AtomicOperations { /// - /// Validates declaration, assignment and reference of local IDs with in a list of operations. + /// Validates declaration, assignment and reference of local IDs within a list of operations. /// public sealed class LocalIdValidator { diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs index 873de53664..80f2a2a454 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs @@ -53,12 +53,12 @@ public virtual async Task> ProcessAsync(IList PostOperationsAsync([FromBody] IList 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. @@ -154,22 +171,5 @@ protected virtual void ValidateModelState(IEnumerable operat throw new InvalidModelStateException(violations, _options.IncludeExceptionStackTraceInErrors, namingStrategy); } } - - 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++; - } - } - } } } diff --git a/src/JsonApiDotNetCore/Middleware/OperationKind.cs b/src/JsonApiDotNetCore/Middleware/OperationKind.cs index 9859b952b1..7cfbe0f892 100644 --- a/src/JsonApiDotNetCore/Middleware/OperationKind.cs +++ b/src/JsonApiDotNetCore/Middleware/OperationKind.cs @@ -1,8 +1,7 @@ namespace JsonApiDotNetCore.Middleware { /// - /// Lists the functional operations from an atomic:operations request. - /// See also . + /// Lists the functional operation kinds from an atomic:operations request. /// public enum OperationKind { diff --git a/src/JsonApiDotNetCore/Middleware/OperationKindExtensions.cs b/src/JsonApiDotNetCore/Middleware/OperationKindExtensions.cs deleted file mode 100644 index 23e582eb73..0000000000 --- a/src/JsonApiDotNetCore/Middleware/OperationKindExtensions.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace JsonApiDotNetCore.Middleware -{ - public static class OperationKindExtensions - { - public static bool IsRelationship(this OperationKind kind) - { - return IsRelationship((OperationKind?)kind); - } - - public static bool IsRelationship(this OperationKind? kind) - { - return kind == OperationKind.SetRelationship || kind == OperationKind.AddToRelationship || - kind == OperationKind.RemoveFromRelationship; - } - } -} From 355083cef819a07aa1df23cdcac3da3f0805f792 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 26 Jan 2021 11:21:39 +0100 Subject: [PATCH 076/123] Refactored tests for HttpReadOnly/NoHttpPost/Patch/Delete controller attributes --- docs/usage/extensibility/controllers.md | 2 +- .../Restricted/ReadOnlyController.cs | 106 --------------- .../HttpReadOnlyTests.cs | 106 --------------- .../NoHttpDeleteTests.cs | 92 ------------- .../NoHttpPatchTests.cs | 92 ------------- .../HttpMethodRestrictions/NoHttpPostTests.cs | 92 ------------- .../RestrictedControllers/Bed.cs | 8 ++ .../BlockingHttpDeleteController.cs | 18 +++ .../BlockingHttpPatchController.cs | 18 +++ .../BlockingHttpPostController.cs | 18 +++ .../BlockingWritesController.cs | 18 +++ .../RestrictedControllers/Chair.cs | 9 ++ .../HttpReadOnlyTests.cs | 126 ++++++++++++++++++ .../NoHttpDeleteTests.cs | 116 ++++++++++++++++ .../RestrictedControllers/NoHttpPatchTests.cs | 116 ++++++++++++++++ .../RestrictedControllers/NoHttpPostTests.cs | 116 ++++++++++++++++ .../RestrictionDbContext.cs | 17 +++ .../RestrictionFakers.cs | 29 ++++ .../RestrictedControllers/Sofa.cs | 8 ++ .../RestrictedControllers/Table.cs | 9 ++ 20 files changed, 627 insertions(+), 489 deletions(-) delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Controllers/Restricted/ReadOnlyController.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/HttpReadOnlyTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Bed.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingHttpDeleteController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingHttpPatchController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingHttpPostController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingWritesController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Chair.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/HttpReadOnlyTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpDeleteTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPatchTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPostTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/RestrictionDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/RestrictionFakers.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Sofa.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Table.cs diff --git a/docs/usage/extensibility/controllers.md b/docs/usage/extensibility/controllers.md index 125c6a23a1..7ff26077e5 100644 --- a/docs/usage/extensibility/controllers.md +++ b/docs/usage/extensibility/controllers.md @@ -74,7 +74,7 @@ The next option is to use the ActionFilter attributes that ship with the library - `HttpReadOnly`: all of the above Not only does this reduce boilerplate, but it also provides a more meaningful HTTP response code. -An attempt to use one blacklisted methods will result in a HTTP 405 Method Not Allowed response. +An attempt to use one of the blacklisted methods will result in a HTTP 405 Method Not Allowed response. ```c# [HttpReadOnly] diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/Restricted/ReadOnlyController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/Restricted/ReadOnlyController.cs deleted file mode 100644 index cbdbdbc2a8..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/Restricted/ReadOnlyController.cs +++ /dev/null @@ -1,106 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExample.Controllers.Restricted -{ - [DisableRoutingConvention, Route("[controller]")] - [HttpReadOnly] - public class ReadOnlyController : BaseJsonApiController
- { - public ReadOnlyController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IResourceService
resourceService) - : base(options, loggerFactory, resourceService) - { } - - [HttpGet] - public IActionResult Get() => Ok(); - - [HttpPost] - public IActionResult Post() => Ok(); - - [HttpPatch] - public IActionResult Patch() => Ok(); - - [HttpDelete] - public IActionResult Delete() => Ok(); - } - - [DisableRoutingConvention, Route("[controller]")] - [NoHttpPost] - public class NoHttpPostController : BaseJsonApiController
- { - public NoHttpPostController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IResourceService
resourceService) - : base(options, loggerFactory, resourceService) - { } - - [HttpGet] - public IActionResult Get() => Ok(); - - [HttpPost] - public IActionResult Post() => Ok(); - - [HttpPatch] - public IActionResult Patch() => Ok(); - - [HttpDelete] - public IActionResult Delete() => Ok(); - } - - [DisableRoutingConvention, Route("[controller]")] - [NoHttpPatch] - public class NoHttpPatchController : BaseJsonApiController
- { - public NoHttpPatchController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IResourceService
resourceService) - : base(options, loggerFactory, resourceService) - { } - - [HttpGet] - public IActionResult Get() => Ok(); - - [HttpPost] - public IActionResult Post() => Ok(); - - [HttpPatch] - public IActionResult Patch() => Ok(); - - [HttpDelete] - public IActionResult Delete() => Ok(); - } - - [DisableRoutingConvention, Route("[controller]")] - [NoHttpDelete] - public class NoHttpDeleteController : BaseJsonApiController
- { - public NoHttpDeleteController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IResourceService
resourceService) - : base(options, loggerFactory, resourceService) - { } - - [HttpGet] - public IActionResult Get() => Ok(); - - [HttpPost] - public IActionResult Post() => Ok(); - - [HttpPatch] - public IActionResult Patch() => Ok(); - - [HttpDelete] - public IActionResult Delete() => Ok(); - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/HttpReadOnlyTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/HttpReadOnlyTests.cs deleted file mode 100644 index 16f660e3f6..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/HttpReadOnlyTests.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - [Collection("WebHostCollection")] - public sealed class HttpReadOnlyTests - { - [Fact] - public async Task Allows_GET_Requests() - { - // Arrange - const string route = "readonly"; - const string method = "GET"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task Rejects_POST_Requests() - { - // Arrange - const string route = "readonly"; - const string method = "POST"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.MethodNotAllowed, errorDocument.Errors[0].StatusCode); - Assert.Equal("The request method is not allowed.", errorDocument.Errors[0].Title); - Assert.Equal("Resource does not support POST requests.", errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Rejects_PATCH_Requests() - { - // Arrange - const string route = "readonly"; - const string method = "PATCH"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.MethodNotAllowed, errorDocument.Errors[0].StatusCode); - Assert.Equal("The request method is not allowed.", errorDocument.Errors[0].Title); - Assert.Equal("Resource does not support PATCH requests.", errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Rejects_DELETE_Requests() - { - // Arrange - const string route = "readonly"; - const string method = "DELETE"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.MethodNotAllowed, errorDocument.Errors[0].StatusCode); - Assert.Equal("The request method is not allowed.", errorDocument.Errors[0].Title); - Assert.Equal("Resource does not support DELETE requests.", errorDocument.Errors[0].Detail); - } - - private async Task MakeRequestAsync(string route, string method) - { - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var httpMethod = new HttpMethod(method); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - var response = await client.SendAsync(request); - return response; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs deleted file mode 100644 index 7103dc3a2c..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpDeleteTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - [Collection("WebHostCollection")] - public sealed class NoHttpDeleteTests - { - [Fact] - public async Task Allows_GET_Requests() - { - // Arrange - const string route = "nohttpdelete"; - const string method = "GET"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task Allows_POST_Requests() - { - // Arrange - const string route = "nohttpdelete"; - const string method = "POST"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task Allows_PATCH_Requests() - { - // Arrange - const string route = "nohttpdelete"; - const string method = "PATCH"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task Rejects_DELETE_Requests() - { - // Arrange - const string route = "nohttpdelete"; - const string method = "DELETE"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.MethodNotAllowed, errorDocument.Errors[0].StatusCode); - Assert.Equal("The request method is not allowed.", errorDocument.Errors[0].Title); - Assert.Equal("Resource does not support DELETE requests.", errorDocument.Errors[0].Detail); - } - - private async Task MakeRequestAsync(string route, string method) - { - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var httpMethod = new HttpMethod(method); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - var response = await client.SendAsync(request); - return response; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs deleted file mode 100644 index 7d26157c46..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPatchTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - [Collection("WebHostCollection")] - public sealed class NoHttpPatchTests - { - [Fact] - public async Task Allows_GET_Requests() - { - // Arrange - const string route = "nohttppatch"; - const string method = "GET"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task Allows_POST_Requests() - { - // Arrange - const string route = "nohttppatch"; - const string method = "POST"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task Rejects_PATCH_Requests() - { - // Arrange - const string route = "nohttppatch"; - const string method = "PATCH"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.MethodNotAllowed, errorDocument.Errors[0].StatusCode); - Assert.Equal("The request method is not allowed.", errorDocument.Errors[0].Title); - Assert.Equal("Resource does not support PATCH requests.", errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Allows_DELETE_Requests() - { - // Arrange - const string route = "nohttppatch"; - const string method = "DELETE"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - private async Task MakeRequestAsync(string route, string method) - { - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var httpMethod = new HttpMethod(method); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - var response = await client.SendAsync(request); - return response; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs deleted file mode 100644 index 23d63eca51..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/HttpMethodRestrictions/NoHttpPostTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - [Collection("WebHostCollection")] - public sealed class NoHttpPostTests - { - [Fact] - public async Task Allows_GET_Requests() - { - // Arrange - const string route = "nohttppost"; - const string method = "GET"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task Rejects_POST_Requests() - { - // Arrange - const string route = "nohttppost"; - const string method = "POST"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.MethodNotAllowed, errorDocument.Errors[0].StatusCode); - Assert.Equal("The request method is not allowed.", errorDocument.Errors[0].Title); - Assert.Equal("Resource does not support POST requests.", errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Allows_PATCH_Requests() - { - // Arrange - const string route = "nohttppost"; - const string method = "PATCH"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task Allows_DELETE_Requests() - { - // Arrange - const string route = "nohttppost"; - const string method = "DELETE"; - - // Act - var response = await MakeRequestAsync(route, method); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - private async Task MakeRequestAsync(string route, string method) - { - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var httpMethod = new HttpMethod(method); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - var response = await client.SendAsync(request); - return response; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Bed.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Bed.cs new file mode 100644 index 0000000000..3aa4747c9c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Bed.cs @@ -0,0 +1,8 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers +{ + public sealed class Bed : Identifiable + { + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingHttpDeleteController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingHttpDeleteController.cs new file mode 100644 index 0000000000..9651950dd3 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingHttpDeleteController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers +{ + [NoHttpDelete] + public sealed class BlockingHttpDeleteController : JsonApiController + { + public BlockingHttpDeleteController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingHttpPatchController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingHttpPatchController.cs new file mode 100644 index 0000000000..9edfdb07c5 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingHttpPatchController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers +{ + [NoHttpPatch] + public sealed class BlockingHttpPatchController : JsonApiController + { + public BlockingHttpPatchController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingHttpPostController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingHttpPostController.cs new file mode 100644 index 0000000000..2dbaaadf80 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingHttpPostController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers +{ + [NoHttpPost] + public sealed class BlockingHttpPostController : JsonApiController + { + public BlockingHttpPostController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingWritesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingWritesController.cs new file mode 100644 index 0000000000..5704bb5600 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingWritesController.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers +{ + [HttpReadOnly] + public sealed class BlockingWritesController : JsonApiController + { + public BlockingWritesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Chair.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Chair.cs new file mode 100644 index 0000000000..e80794f0e7 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Chair.cs @@ -0,0 +1,9 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers +{ + public sealed class Chair : Identifiable + { + + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/HttpReadOnlyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/HttpReadOnlyTests.cs new file mode 100644 index 0000000000..2cefccbf8d --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/HttpReadOnlyTests.cs @@ -0,0 +1,126 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers +{ + public sealed class HttpReadOnlyTests + : IClassFixture, RestrictionDbContext>> + { + private readonly IntegrationTestContext, RestrictionDbContext> _testContext; + private readonly RestrictionFakers _fakers = new RestrictionFakers(); + + public HttpReadOnlyTests(IntegrationTestContext, RestrictionDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_get_resources() + { + // Arrange + var route = "/beds"; + + // Act + var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task Cannot_create_resource() + { + // Arrange + var requestBody = new + { + data = new + { + type = "beds", + attributes = new + { + } + } + }; + + var route = "/beds"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.MethodNotAllowed); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); + responseDocument.Errors[0].Title.Should().Be("The request method is not allowed."); + responseDocument.Errors[0].Detail.Should().Be("Resource does not support POST requests."); + } + + [Fact] + public async Task Cannot_update_resource() + { + // Arrange + var existingBed = _fakers.Bed.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Beds.Add(existingBed); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "beds", + id = existingBed.StringId, + attributes = new + { + } + } + }; + + var route = "/beds/" + existingBed.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.MethodNotAllowed); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); + responseDocument.Errors[0].Title.Should().Be("The request method is not allowed."); + responseDocument.Errors[0].Detail.Should().Be("Resource does not support PATCH requests."); + } + + [Fact] + public async Task Cannot_delete_resource() + { + // Arrange + var existingBed = _fakers.Bed.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Beds.Add(existingBed); + await dbContext.SaveChangesAsync(); + }); + + var route = "/beds/" + existingBed.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.MethodNotAllowed); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); + responseDocument.Errors[0].Title.Should().Be("The request method is not allowed."); + responseDocument.Errors[0].Detail.Should().Be("Resource does not support DELETE requests."); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpDeleteTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpDeleteTests.cs new file mode 100644 index 0000000000..d8160f1b60 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpDeleteTests.cs @@ -0,0 +1,116 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers +{ + public sealed class NoHttpDeleteTests + : IClassFixture, RestrictionDbContext>> + { + private readonly IntegrationTestContext, RestrictionDbContext> _testContext; + private readonly RestrictionFakers _fakers = new RestrictionFakers(); + + public NoHttpDeleteTests(IntegrationTestContext, RestrictionDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_get_resources() + { + // Arrange + var route = "/sofas"; + + // Act + var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task Can_create_resource() + { + // Arrange + var requestBody = new + { + data = new + { + type = "sofas", + attributes = new + { + } + } + }; + + var route = "/sofas"; + + // Act + var (httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + } + + [Fact] + public async Task Can_update_resource() + { + // Arrange + var existingSofa = _fakers.Sofa.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Sofas.Add(existingSofa); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "sofas", + id = existingSofa.StringId, + attributes = new + { + } + } + }; + + var route = "/sofas/" + existingSofa.StringId; + + // Act + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Cannot_delete_resource() + { + // Arrange + var existingSofa = _fakers.Sofa.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Sofas.Add(existingSofa); + await dbContext.SaveChangesAsync(); + }); + + var route = "/sofas/" + existingSofa.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.MethodNotAllowed); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); + responseDocument.Errors[0].Title.Should().Be("The request method is not allowed."); + responseDocument.Errors[0].Detail.Should().Be("Resource does not support DELETE requests."); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPatchTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPatchTests.cs new file mode 100644 index 0000000000..62484888d0 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPatchTests.cs @@ -0,0 +1,116 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers +{ + public sealed class NoHttpPatchTests + : IClassFixture, RestrictionDbContext>> + { + private readonly IntegrationTestContext, RestrictionDbContext> _testContext; + private readonly RestrictionFakers _fakers = new RestrictionFakers(); + + public NoHttpPatchTests(IntegrationTestContext, RestrictionDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_get_resources() + { + // Arrange + var route = "/chairs"; + + // Act + var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task Can_create_resource() + { + // Arrange + var requestBody = new + { + data = new + { + type = "chairs", + attributes = new + { + } + } + }; + + var route = "/chairs"; + + // Act + var (httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + } + + [Fact] + public async Task Cannot_update_resource() + { + // Arrange + var existingChair = _fakers.Chair.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Chairs.Add(existingChair); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "chairs", + id = existingChair.StringId, + attributes = new + { + } + } + }; + + var route = "/chairs/" + existingChair.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.MethodNotAllowed); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); + responseDocument.Errors[0].Title.Should().Be("The request method is not allowed."); + responseDocument.Errors[0].Detail.Should().Be("Resource does not support PATCH requests."); + } + + [Fact] + public async Task Can_delete_resource() + { + // Arrange + var existingChair = _fakers.Chair.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Chairs.Add(existingChair); + await dbContext.SaveChangesAsync(); + }); + + var route = "/chairs/" + existingChair.StringId; + + // Act + var (httpResponse, _) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPostTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPostTests.cs new file mode 100644 index 0000000000..d8f5fda941 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPostTests.cs @@ -0,0 +1,116 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers +{ + public sealed class NoHttpPostTests + : IClassFixture, RestrictionDbContext>> + { + private readonly IntegrationTestContext, RestrictionDbContext> _testContext; + private readonly RestrictionFakers _fakers = new RestrictionFakers(); + + public NoHttpPostTests(IntegrationTestContext, RestrictionDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_get_resources() + { + // Arrange + var route = "/tables"; + + // Act + var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + } + + [Fact] + public async Task Cannot_create_resource() + { + // Arrange + var requestBody = new + { + data = new + { + type = "tables", + attributes = new + { + } + } + }; + + var route = "/tables"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.MethodNotAllowed); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.MethodNotAllowed); + responseDocument.Errors[0].Title.Should().Be("The request method is not allowed."); + responseDocument.Errors[0].Detail.Should().Be("Resource does not support POST requests."); + } + + [Fact] + public async Task Can_update_resource() + { + // Arrange + var existingTable = _fakers.Table.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Tables.Add(existingTable); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "tables", + id = existingTable.StringId, + attributes = new + { + } + } + }; + + var route = "/tables/" + existingTable.StringId; + + // Act + var (httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + } + + [Fact] + public async Task Can_delete_resource() + { + // Arrange + var existingTable = _fakers.Table.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Tables.Add(existingTable); + await dbContext.SaveChangesAsync(); + }); + + var route = "/tables/" + existingTable.StringId; + + // Act + var (httpResponse, _) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/RestrictionDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/RestrictionDbContext.cs new file mode 100644 index 0000000000..190df5f4d1 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/RestrictionDbContext.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers +{ + public sealed class RestrictionDbContext : DbContext + { + public DbSet
Tables { get; set; } + public DbSet Chairs { get; set; } + public DbSet Sofas { get; set; } + public DbSet Beds { get; set; } + + public RestrictionDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/RestrictionFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/RestrictionFakers.cs new file mode 100644 index 0000000000..70d2dab8a3 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/RestrictionFakers.cs @@ -0,0 +1,29 @@ +using System; +using Bogus; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers +{ + internal sealed class RestrictionFakers : FakerContainer + { + private readonly Lazy> _lazyTableFaker = new Lazy>(() => + new Faker
() + .UseSeed(GetFakerSeed())); + + private readonly Lazy> _lazyChairFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed())); + + private readonly Lazy> _lazySofaFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed())); + + private readonly Lazy> _lazyBedFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed())); + + public Faker
Table => _lazyTableFaker.Value; + public Faker Chair => _lazyChairFaker.Value; + public Faker Sofa => _lazySofaFaker.Value; + public Faker Bed => _lazyBedFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Sofa.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Sofa.cs new file mode 100644 index 0000000000..4c5c84ce20 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Sofa.cs @@ -0,0 +1,8 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers +{ + public sealed class Sofa : Identifiable + { + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Table.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Table.cs new file mode 100644 index 0000000000..af649cc581 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Table.cs @@ -0,0 +1,9 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers +{ + public sealed class Table : Identifiable + { + + } +} From 90274c604e0208f6678c040c888d6021e88f7d40 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 26 Jan 2021 13:51:44 +0100 Subject: [PATCH 077/123] Refactored tests for casing convention --- .../Data/AppDbContext.cs | 1 - .../Models/KebabCasedModel.cs | 11 - .../Acceptance/KebabCaseFormatterTests.cs | 201 ------------------ .../Acceptance/TestFixture.cs | 56 +---- .../NamingConventions/DivingBoard.cs | 14 ++ .../DivingBoardsController.cs | 16 ++ .../KebabCasingConventionStartup.cs | 29 +++ .../NamingConventions/KebabCasingTests.cs | 196 +++++++++++++++++ .../NamingConventions/SwimmingDbContext.cs | 16 ++ .../NamingConventions/SwimmingFakers.cs | 27 +++ .../NamingConventions/SwimmingPool.cs | 18 ++ .../SwimmingPoolsController.cs | 16 ++ .../NamingConventions/WaterSlide.cs | 11 + .../ObjectAssertionsExtensions.cs | 12 ++ 14 files changed, 356 insertions(+), 268 deletions(-) delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Models/KebabCasedModel.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/DivingBoard.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/DivingBoardsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingFakers.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingPool.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/WaterSlide.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index 7221b18492..198169edcb 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -13,7 +13,6 @@ public sealed class AppDbContext : DbContext public DbSet Passports { get; set; } public DbSet People { get; set; } public DbSet TodoItemCollections { get; set; } - public DbSet KebabCasedModels { get; set; } public DbSet
Articles { get; set; } public DbSet AuthorDifferentDbContextName { get; set; } public DbSet Users { get; set; } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/KebabCasedModel.cs b/src/Examples/JsonApiDotNetCoreExample/Models/KebabCasedModel.cs deleted file mode 100644 index d9e4c4bbf9..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Models/KebabCasedModel.cs +++ /dev/null @@ -1,11 +0,0 @@ -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExample.Models -{ - public class KebabCasedModel : Identifiable - { - [Attr] - public string CompoundAttr { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs deleted file mode 100644 index 1d995bb721..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/KebabCaseFormatterTests.cs +++ /dev/null @@ -1,201 +0,0 @@ -using System; -using System.Linq.Expressions; -using System.Net; -using System.Threading.Tasks; -using Bogus; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Client.Internal; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json.Linq; -using Newtonsoft.Json.Serialization; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - public sealed class KebabCaseFormatterTests : IClassFixture> - { - private readonly IntegrationTestContext _testContext; - private readonly Faker _faker; - - public KebabCaseFormatterTests(IntegrationTestContext testContext) - { - _testContext = testContext; - - _faker = new Faker() - .RuleFor(m => m.CompoundAttr, f => f.Lorem.Sentence()); - } - - [Fact] - public async Task KebabCaseFormatter_GetAll_IsReturned() - { - // Arrange - var model = _faker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.KebabCasedModels.Add(model); - - await dbContext.SaveChangesAsync(); - }); - - var route = "api/v1/kebab-cased-models"; - - // 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(model.StringId); - responseDocument.ManyData[0].Attributes["compound-attr"].Should().Be(model.CompoundAttr); - } - - [Fact] - public async Task KebabCaseFormatter_GetSingle_IsReturned() - { - // Arrange - var model = _faker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.KebabCasedModels.Add(model); - - await dbContext.SaveChangesAsync(); - }); - - var route = "api/v1/kebab-cased-models/" + model.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(model.StringId); - responseDocument.SingleData.Attributes["compound-attr"].Should().Be(model.CompoundAttr); - } - - [Fact] - public async Task KebabCaseFormatter_Create_IsCreated() - { - // Arrange - var model = _faker.Generate(); - var serializer = GetSerializer(kcm => new { kcm.CompoundAttr }); - - var route = "api/v1/kebab-cased-models"; - - var requestBody = serializer.Serialize(model); - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["compound-attr"].Should().Be(model.CompoundAttr); - } - - [Fact] - public async Task KebabCaseFormatter_Update_IsUpdated() - { - // Arrange - var model = _faker.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.KebabCasedModels.Add(model); - - await dbContext.SaveChangesAsync(); - }); - - model.CompoundAttr = _faker.Generate().CompoundAttr; - var serializer = GetSerializer(kcm => new { kcm.CompoundAttr }); - - var route = "api/v1/kebab-cased-models/" + model.StringId; - - var requestBody = serializer.Serialize(model); - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - var stored = await dbContext.KebabCasedModels.SingleAsync(x => x.Id == model.Id); - Assert.Equal(model.CompoundAttr, stored.CompoundAttr); - }); - } - - [Fact] - public async Task KebabCaseFormatter_ErrorWithStackTrace_CasingConventionIsApplied() - { - // Arrange - var route = "api/v1/kebab-cased-models/1"; - - const string requestBody = "{ \"data\": {"; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - var meta = responseDocument["errors"][0]["meta"]; - Assert.NotNull(meta["stack-trace"]); - } - - private IRequestSerializer GetSerializer(Expression> attributes = null, Expression> relationships = null) where TResource : class, IIdentifiable - { - using var scope = _testContext.Factory.Services.CreateScope(); - var serializer = scope.ServiceProvider.GetRequiredService(); - var graph = scope.ServiceProvider.GetRequiredService(); - - serializer.AttributesToSerialize = attributes != null ? graph.GetAttributes(attributes) : null; - serializer.RelationshipsToSerialize = relationships != null ? graph.GetRelationships(relationships) : null; - - return serializer; - } - } - - public sealed class KebabCaseStartup : TestStartup - { - public KebabCaseStartup(IConfiguration configuration) : base(configuration) - { - } - - protected override void ConfigureJsonApiOptions(JsonApiOptions options) - { - base.ConfigureJsonApiOptions(options); - - ((DefaultContractResolver)options.SerializerSettings.ContractResolver).NamingStrategy = new KebabCaseNamingStrategy(); - } - } - - public sealed class KebabCasedModelsController : JsonApiController - { - public KebabCasedModelsController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs index 8054663ef3..9136abc00c 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs @@ -1,21 +1,12 @@ using System; -using System.Linq.Expressions; using System.Net; using System.Net.Http; -using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Repositories; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Client.Internal; using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.Helpers.Models; using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace JsonApiDotNetCoreExampleTests.Acceptance @@ -35,55 +26,10 @@ public TestFixture() } public HttpClient Client { get; set; } - public AppDbContext Context { get; private set; } - - public static IRequestSerializer GetSerializer(IServiceProvider serviceProvider, Expression> attributes = null, Expression> relationships = null) where TResource : class, IIdentifiable - { - var serializer = (IRequestSerializer)serviceProvider.GetRequiredService(typeof(IRequestSerializer)); - var graph = (IResourceGraph)serviceProvider.GetRequiredService(typeof(IResourceGraph)); - serializer.AttributesToSerialize = attributes != null ? graph.GetAttributes(attributes) : null; - serializer.RelationshipsToSerialize = relationships != null ? graph.GetRelationships(relationships) : null; - return serializer; - } - - public IRequestSerializer GetSerializer(Expression> attributes = null, Expression> relationships = null) where TResource : class, IIdentifiable - { - var serializer = GetRequiredService(); - var graph = GetRequiredService(); - serializer.AttributesToSerialize = attributes != null ? graph.GetAttributes(attributes) : null; - serializer.RelationshipsToSerialize = relationships != null ? graph.GetRelationships(relationships) : null; - return serializer; - } - - public IResponseDeserializer GetDeserializer() - { - var options = GetRequiredService(); - - var resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance) - .Add() - .Add
() - .Add() - .Add() - .Add() - .Add() - .Add() - .Add() - .Add() - .Add("todoItems") - .Add().Build(); - return new ResponseDeserializer(resourceGraph, new ResourceFactory(ServiceProvider)); - } + public AppDbContext Context { get; } public T GetRequiredService() => (T)ServiceProvider.GetRequiredService(typeof(T)); - public void ReloadDbContext() - { - ISystemClock systemClock = ServiceProvider.GetRequiredService(); - DbContextOptions options = GetRequiredService>(); - - Context = new AppDbContext(options, systemClock); - } - public void AssertEqualStatusCode(HttpStatusCode expected, HttpResponseMessage response) { var responseBody = response.Content.ReadAsStringAsync().Result; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/DivingBoard.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/DivingBoard.cs new file mode 100644 index 0000000000..c46c4f410f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/DivingBoard.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions +{ + public sealed class DivingBoard : Identifiable + { + [Attr] + [Required] + [Range(1, 20)] + public decimal HeightInMeters { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/DivingBoardsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/DivingBoardsController.cs new file mode 100644 index 0000000000..826249bb22 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/DivingBoardsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions +{ + public sealed class DivingBoardsController : JsonApiController + { + public DivingBoardsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs new file mode 100644 index 0000000000..44fd3af114 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs @@ -0,0 +1,29 @@ +using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json.Serialization; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions +{ + public sealed class KebabCasingConventionStartup : TestableStartup + where TDbContext : DbContext + { + public KebabCasingConventionStartup(IConfiguration configuration) : base(configuration) + { + } + + protected override void SetJsonApiOptions(JsonApiOptions options) + { + base.SetJsonApiOptions(options); + + options.IncludeExceptionStackTraceInErrors = true; + options.Namespace = "public-api"; + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; + options.ValidateModelState = true; + + var resolver = (DefaultContractResolver) options.SerializerSettings.ContractResolver; + resolver!.NamingStrategy = new KebabCaseNamingStrategy(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs new file mode 100644 index 0000000000..6b5929328b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs @@ -0,0 +1,196 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions +{ + public sealed class KebabCasingTests + : IClassFixture, SwimmingDbContext>> + { + private readonly IntegrationTestContext, SwimmingDbContext> _testContext; + private readonly SwimmingFakers _fakers = new SwimmingFakers(); + + public KebabCasingTests(IntegrationTestContext, SwimmingDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_get_resources_with_include() + { + // Arrange + var pools = _fakers.SwimmingPool.Generate(2); + pools[1].DivingBoards = _fakers.DivingBoard.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.SwimmingPools.AddRange(pools); + await dbContext.SaveChangesAsync(); + }); + + var route = "/public-api/swimming-pools?include=diving-boards"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + responseDocument.ManyData.Should().OnlyContain(resourceObject => resourceObject.Type == "swimming-pools"); + responseDocument.ManyData.Should().OnlyContain(resourceObject => resourceObject.Attributes.ContainsKey("is-indoor")); + responseDocument.ManyData.Should().OnlyContain(resourceObject => resourceObject.Relationships.ContainsKey("water-slides")); + responseDocument.ManyData.Should().OnlyContain(resourceObject => resourceObject.Relationships.ContainsKey("diving-boards")); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("diving-boards"); + responseDocument.Included[0].Id.Should().Be(pools[1].DivingBoards[0].StringId); + responseDocument.Included[0].Attributes["height-in-meters"].Should().BeApproximately(pools[1].DivingBoards[0].HeightInMeters); + responseDocument.Included[0].Relationships.Should().BeNull(); + responseDocument.Included[0].Links.Self.Should().Be($"/public-api/diving-boards/{pools[1].DivingBoards[0].StringId}"); + + responseDocument.Meta["total-resources"].Should().Be(2); + } + + [Fact] + public async Task Can_filter_secondary_resources_with_sparse_fieldset() + { + // Arrange + var pool = _fakers.SwimmingPool.Generate(); + pool.WaterSlides = _fakers.WaterSlide.Generate(2); + pool.WaterSlides[0].LengthInMeters = 1; + pool.WaterSlides[1].LengthInMeters = 5; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.SwimmingPools.Add(pool); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/public-api/swimming-pools/{pool.StringId}/water-slides?filter=greaterThan(length-in-meters,'1')&fields[water-slides]=length-in-meters"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Type.Should().Be("water-slides"); + responseDocument.ManyData[0].Id.Should().Be(pool.WaterSlides[1].StringId); + responseDocument.ManyData[0].Attributes.Should().HaveCount(1); + } + + [Fact] + public async Task Can_create_resource() + { + // Arrange + var newPool = _fakers.SwimmingPool.Generate(); + + var requestBody = new + { + data = new + { + type = "swimming-pools", + attributes = new Dictionary + { + ["is-indoor"] = newPool.IsIndoor + } + } + }; + + var route = "/public-api/swimming-pools"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("swimming-pools"); + responseDocument.SingleData.Attributes["is-indoor"].Should().Be(newPool.IsIndoor); + + var newPoolId = int.Parse(responseDocument.SingleData.Id); + + responseDocument.SingleData.Relationships.Should().NotBeEmpty(); + responseDocument.SingleData.Relationships["water-slides"].Links.Self.Should().Be($"/public-api/swimming-pools/{newPoolId}/relationships/water-slides"); + responseDocument.SingleData.Relationships["water-slides"].Links.Related.Should().Be($"/public-api/swimming-pools/{newPoolId}/water-slides"); + responseDocument.SingleData.Relationships["diving-boards"].Links.Self.Should().Be($"/public-api/swimming-pools/{newPoolId}/relationships/diving-boards"); + responseDocument.SingleData.Relationships["diving-boards"].Links.Related.Should().Be($"/public-api/swimming-pools/{newPoolId}/diving-boards"); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var poolInDatabase = await dbContext.SwimmingPools + .FirstAsync(pool => pool.Id == newPoolId); + + poolInDatabase.IsIndoor.Should().Be(newPool.IsIndoor); + }); + } + + [Fact] + public async Task Cannot_create_resource_for_invalid_request_body() + { + // Arrange + var requestBody = "{ \"data\": {"; + + var route = "/public-api/swimming-pools"; + + // 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."); + responseDocument.Errors[0].Meta.Data.Should().ContainKey("stack-trace"); + } + + [Fact] + public async Task Cannot_update_resource_for_invalid_attribute() + { + // Arrange + var existingBoard = _fakers.DivingBoard.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.DivingBoards.Add(existingBoard); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "diving-boards", + id = existingBoard.StringId, + attributes = new Dictionary + { + ["height-in-meters"] = -1 + } + } + }; + + var route = "/public-api/diving-boards/" + existingBoard.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(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("Input validation failed."); + responseDocument.Errors[0].Detail.Should().Be("The field HeightInMeters must be between 1 and 20."); + responseDocument.Errors[0].Source.Pointer.Should().Be("/data/attributes/height-in-meters"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingDbContext.cs new file mode 100644 index 0000000000..9d8de4aa26 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingDbContext.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions +{ + public sealed class SwimmingDbContext : DbContext + { + public DbSet SwimmingPools { get; set; } + public DbSet WaterSlides { get; set; } + public DbSet DivingBoards { get; set; } + + public SwimmingDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingFakers.cs new file mode 100644 index 0000000000..71615bc567 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingFakers.cs @@ -0,0 +1,27 @@ +using System; +using Bogus; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions +{ + internal sealed class SwimmingFakers : FakerContainer + { + private readonly Lazy> _lazySwimmingPoolFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(swimmingPool => swimmingPool.IsIndoor, f => f.Random.Bool())); + + private readonly Lazy> _lazyWaterSlideFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(waterSlide => waterSlide.LengthInMeters, f => f.Random.Decimal(3, 100))); + + private readonly Lazy> _lazyDivingBoardFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(divingBoard => divingBoard.HeightInMeters, f => f.Random.Decimal(1, 15))); + + public Faker SwimmingPool => _lazySwimmingPoolFaker.Value; + public Faker WaterSlide => _lazyWaterSlideFaker.Value; + public Faker DivingBoard => _lazyDivingBoardFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingPool.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingPool.cs new file mode 100644 index 0000000000..6c1274816e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingPool.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions +{ + public sealed class SwimmingPool : Identifiable + { + [Attr] + public bool IsIndoor { get; set; } + + [HasMany] + public IList WaterSlides { get; set; } + + [HasMany] + public IList DivingBoards { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs new file mode 100644 index 0000000000..6af99a7ea4 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions +{ + public sealed class SwimmingPoolsController : JsonApiController + { + public SwimmingPoolsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/WaterSlide.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/WaterSlide.cs new file mode 100644 index 0000000000..b7dea1d5ba --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/WaterSlide.cs @@ -0,0 +1,11 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions +{ + public sealed class WaterSlide : Identifiable + { + [Attr] + public decimal LengthInMeters { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs index c4ab87ea6d..159a7691b4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs @@ -24,5 +24,17 @@ public static void BeCloseTo(this ObjectAssertions source, DateTimeOffset? expec value.Should().BeCloseTo(expected.Value, because: because, becauseArgs: becauseArgs); } } + + /// + /// Used to assert on a column, whose value is returned as in json:api response body. + /// + public static void BeApproximately(this ObjectAssertions source, decimal? expected, decimal precision = 0.00000000001M, string because = "", + params object[] becauseArgs) + { + // We lose a little bit of precision on roundtrip through PostgreSQL database. + + var value = (decimal?) (double) source.Subject; + value.Should().BeApproximately(expected, precision, because, becauseArgs); + } } } From 4bdb3e89bee2279b7ca72ec5ab708f07a12eed2c Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 26 Jan 2021 15:15:48 +0100 Subject: [PATCH 078/123] Refactored tests for custom exception handler --- .../Extensibility/CustomErrorHandlingTests.cs | 80 ----------------- .../AlternateExceptionHandler.cs | 37 ++++++++ .../ExceptionHandling/ConsumerArticle.cs | 11 +++ ...umerArticleIsNoLongerAvailableException.cs | 23 +++++ .../ConsumerArticleService.cs | 39 +++++++++ .../ConsumerArticlesController.cs | 16 ++++ .../ExceptionHandling/ErrorDbContext.cs | 14 +++ .../ExceptionHandlerTests.cs | 85 +++++++++++++++++++ 8 files changed, 225 insertions(+), 80 deletions(-) delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticle.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticleIsNoLongerAvailableException.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticlesController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs deleted file mode 100644 index ff49489904..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomErrorHandlingTests.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System; -using System.Linq; -using System.Net; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.Extensions.Logging; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Extensibility -{ - public sealed class CustomErrorHandlingTests - { - [Fact] - public void When_using_custom_exception_handler_it_must_create_error_document_and_log() - { - // Arrange - var loggerFactory = new FakeLoggerFactory(); - var options = new JsonApiOptions {IncludeExceptionStackTraceInErrors = true}; - var handler = new CustomExceptionHandler(loggerFactory, options); - - // Act - var errorDocument = handler.HandleException(new NoPermissionException("YouTube")); - - // Assert - Assert.Single(errorDocument.Errors); - Assert.Equal("For support, email to: support@company.com?subject=YouTube", - errorDocument.Errors[0].Meta.Data["support"]); - Assert.NotEmpty((string[]) errorDocument.Errors[0].Meta.Data["StackTrace"]); - - Assert.Single(loggerFactory.Logger.Messages); - Assert.Equal(LogLevel.Warning, loggerFactory.Logger.Messages.Single().LogLevel); - Assert.Contains("Access is denied.", loggerFactory.Logger.Messages.Single().Text); - } - - public class CustomExceptionHandler : ExceptionHandler - { - public CustomExceptionHandler(ILoggerFactory loggerFactory, IJsonApiOptions options) - : base(loggerFactory, options) - { - } - - protected override LogLevel GetLogLevel(Exception exception) - { - if (exception is NoPermissionException) - { - return LogLevel.Warning; - } - - return base.GetLogLevel(exception); - } - - protected override ErrorDocument CreateErrorDocument(Exception exception) - { - if (exception is NoPermissionException noPermissionException) - { - noPermissionException.Errors[0].Meta.Data.Add("support", - "For support, email to: support@company.com?subject=" + noPermissionException.CustomerCode); - } - - return base.CreateErrorDocument(exception); - } - } - - public class NoPermissionException : JsonApiException - { - public string CustomerCode { get; } - - public NoPermissionException(string customerCode) : base(new Error(HttpStatusCode.Forbidden) - { - Title = "Access is denied.", - Detail = $"Customer '{customerCode}' does not have permission to access this location." - }) - { - CustomerCode = customerCode; - } - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs new file mode 100644 index 0000000000..385d2d6493 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs @@ -0,0 +1,37 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling +{ + public sealed class AlternateExceptionHandler : ExceptionHandler + { + public AlternateExceptionHandler(ILoggerFactory loggerFactory, IJsonApiOptions options) + : base(loggerFactory, options) + { + } + + protected override LogLevel GetLogLevel(Exception exception) + { + if (exception is ConsumerArticleIsNoLongerAvailableException) + { + return LogLevel.Warning; + } + + return base.GetLogLevel(exception); + } + + protected override ErrorDocument CreateErrorDocument(Exception exception) + { + if (exception is ConsumerArticleIsNoLongerAvailableException articleException) + { + articleException.Errors[0].Meta.Data.Add("support", + $"Please contact us for info about similar articles at {articleException.SupportEmailAddress}."); + } + + return base.CreateErrorDocument(exception); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticle.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticle.cs new file mode 100644 index 0000000000..320355edfa --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticle.cs @@ -0,0 +1,11 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling +{ + public sealed class ConsumerArticle : Identifiable + { + [Attr] + public string Code { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticleIsNoLongerAvailableException.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticleIsNoLongerAvailableException.cs new file mode 100644 index 0000000000..f4420dea90 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticleIsNoLongerAvailableException.cs @@ -0,0 +1,23 @@ +using System.Net; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling +{ + public sealed class ConsumerArticleIsNoLongerAvailableException : JsonApiException + { + public string ArticleCode { get; } + public string SupportEmailAddress { get; } + + public ConsumerArticleIsNoLongerAvailableException(string articleCode, string supportEmailAddress) + : base(new Error(HttpStatusCode.Gone) + { + Title = "The requested article is no longer available.", + Detail = $"Article with code '{articleCode}' is no longer available." + }) + { + ArticleCode = articleCode; + SupportEmailAddress = supportEmailAddress; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs new file mode 100644 index 0000000000..17daa6233a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs @@ -0,0 +1,39 @@ +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Hooks; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling +{ + public sealed class ConsumerArticleService : JsonApiResourceService + { + private const string _supportEmailAddress = "company@email.com"; + + public ConsumerArticleService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, + IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, + IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, + IResourceHookExecutorFacade hookExecutor) + : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, + resourceChangeTracker, hookExecutor) + { + } + + public override async Task GetAsync(int id, CancellationToken cancellationToken) + { + var consumerArticle = await base.GetAsync(id, cancellationToken); + + if (consumerArticle.Code.StartsWith("X")) + { + throw new ConsumerArticleIsNoLongerAvailableException(consumerArticle.Code, _supportEmailAddress); + } + + return consumerArticle; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticlesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticlesController.cs new file mode 100644 index 0000000000..dcc0ad7e8e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticlesController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling +{ + public sealed class ConsumerArticlesController : JsonApiController + { + public ConsumerArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs new file mode 100644 index 0000000000..2e9f0cc3e8 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling +{ + public sealed class ErrorDbContext : DbContext + { + public DbSet ConsumerArticles { get; set; } + + public ErrorDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs new file mode 100644 index 0000000000..fca27f6857 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs @@ -0,0 +1,85 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling +{ + public sealed class ExceptionHandlerTests + : IClassFixture, ErrorDbContext>> + { + private readonly IntegrationTestContext, ErrorDbContext> _testContext; + + public ExceptionHandlerTests(IntegrationTestContext, ErrorDbContext> testContext) + { + _testContext = testContext; + + FakeLoggerFactory loggerFactory = null; + + testContext.ConfigureLogging(options => + { + loggerFactory = new FakeLoggerFactory(); + + options.ClearProviders(); + options.AddProvider(loggerFactory); + options.SetMinimumLevel(LogLevel.Warning); + }); + + testContext.ConfigureServicesBeforeStartup(services => + { + if (loggerFactory != null) + { + services.AddSingleton(_ => loggerFactory); + } + }); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceService(); + services.AddScoped(); + }); + } + + [Fact] + public async Task Logs_and_produces_error_response_for_custom_exception() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); + + var consumerArticle = new ConsumerArticle + { + Code = "X123" + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.ConsumerArticles.Add(consumerArticle); + await dbContext.SaveChangesAsync(); + }); + + var route = "/consumerArticles/" + consumerArticle.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Gone); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Gone); + responseDocument.Errors[0].Title.Should().Be("The requested article is no longer available."); + responseDocument.Errors[0].Detail.Should().Be("Article with code 'X123' is no longer available."); + responseDocument.Errors[0].Meta.Data["support"].Should().Be("Please contact us for info about similar articles at company@email.com."); + + loggerFactory.Logger.Messages.Should().HaveCount(1); + loggerFactory.Logger.Messages.Single().Text.Should().Contain("Article with code 'X123' is no longer available."); + } + } +} From cbe01c229c02e4b96dd45f27f6e85a60b3c8cd8c Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 26 Jan 2021 17:41:54 +0100 Subject: [PATCH 079/123] Refactored tests for nulls/default query string parameters; removed some duplication and fixed an equality bug. --- .../Building/ResourceObjectBuilder.cs | 2 +- .../Extensibility/IgnoreDefaultValuesTests.cs | 172 ----------------- .../Extensibility/IgnoreNullValuesTests.cs | 175 ------------------ .../QueryStrings/Appointment.cs | 18 ++ .../IntegrationTests/QueryStrings/Calendar.cs | 18 ++ .../QueryStrings/CalendarsController.cs | 16 ++ .../QueryStrings/QueryStringDbContext.cs | 14 ++ .../QueryStrings/QueryStringFakers.cs | 24 +++ .../QueryStrings/QueryStringTests.cs | 16 +- .../SerializerDefaultValueHandlingTests.cs | 97 ++++++++++ .../SerializerNullValueHandlingTests.cs | 97 ++++++++++ 11 files changed, 293 insertions(+), 356 deletions(-) delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreDefaultValuesTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreNullValuesTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Appointment.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Calendar.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/CalendarsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringFakers.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerDefaultValueHandlingTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerNullValueHandlingTests.cs diff --git a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs index 63df921f96..28808bb6ba 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs @@ -146,7 +146,7 @@ private void ProcessAttributes(IIdentifiable resource, IEnumerable fixture) - { - _dbContext = fixture.GetRequiredService(); - _todoItem = new TodoItem - { - CreatedDate = default, - Owner = new Person { Age = default } - }; - _dbContext.TodoItems.Add(_todoItem); - } - - public async Task InitializeAsync() - { - await _dbContext.SaveChangesAsync(); - } - - public Task DisposeAsync() - { - return Task.CompletedTask; - } - - [Theory] - [InlineData(null, null, null, DefaultValueHandling.Include)] - [InlineData(null, null, "false", DefaultValueHandling.Include)] - [InlineData(null, null, "true", DefaultValueHandling.Include)] - [InlineData(null, null, "unknown", null)] - [InlineData(null, null, "", null)] - [InlineData(null, false, null, DefaultValueHandling.Include)] - [InlineData(null, false, "false", DefaultValueHandling.Include)] - [InlineData(null, false, "true", DefaultValueHandling.Include)] - [InlineData(null, false, "unknown", null)] - [InlineData(null, false, "", null)] - [InlineData(null, true, null, DefaultValueHandling.Include)] - [InlineData(null, true, "false", DefaultValueHandling.Ignore)] - [InlineData(null, true, "true", DefaultValueHandling.Include)] - [InlineData(null, true, "unknown", null)] - [InlineData(null, true, "", null)] - [InlineData(DefaultValueHandling.Ignore, null, null, DefaultValueHandling.Ignore)] - [InlineData(DefaultValueHandling.Ignore, null, "false", DefaultValueHandling.Ignore)] - [InlineData(DefaultValueHandling.Ignore, null, "true", DefaultValueHandling.Ignore)] - [InlineData(DefaultValueHandling.Ignore, null, "unknown", null)] - [InlineData(DefaultValueHandling.Ignore, null, "", null)] - [InlineData(DefaultValueHandling.Ignore, false, null, DefaultValueHandling.Ignore)] - [InlineData(DefaultValueHandling.Ignore, false, "false", DefaultValueHandling.Ignore)] - [InlineData(DefaultValueHandling.Ignore, false, "true", DefaultValueHandling.Ignore)] - [InlineData(DefaultValueHandling.Ignore, false, "unknown", null)] - [InlineData(DefaultValueHandling.Ignore, false, "", null)] - [InlineData(DefaultValueHandling.Ignore, true, null, DefaultValueHandling.Ignore)] - [InlineData(DefaultValueHandling.Ignore, true, "false", DefaultValueHandling.Ignore)] - [InlineData(DefaultValueHandling.Ignore, true, "true", DefaultValueHandling.Include)] - [InlineData(DefaultValueHandling.Ignore, true, "unknown", null)] - [InlineData(DefaultValueHandling.Ignore, true, "", null)] - [InlineData(DefaultValueHandling.Include, null, null, DefaultValueHandling.Include)] - [InlineData(DefaultValueHandling.Include, null, "false", DefaultValueHandling.Include)] - [InlineData(DefaultValueHandling.Include, null, "true", DefaultValueHandling.Include)] - [InlineData(DefaultValueHandling.Include, null, "unknown", null)] - [InlineData(DefaultValueHandling.Include, null, "", null)] - [InlineData(DefaultValueHandling.Include, false, null, DefaultValueHandling.Include)] - [InlineData(DefaultValueHandling.Include, false, "false", DefaultValueHandling.Include)] - [InlineData(DefaultValueHandling.Include, false, "true", DefaultValueHandling.Include)] - [InlineData(DefaultValueHandling.Include, false, "unknown", null)] - [InlineData(DefaultValueHandling.Include, false, "", null)] - [InlineData(DefaultValueHandling.Include, true, null, DefaultValueHandling.Include)] - [InlineData(DefaultValueHandling.Include, true, "false", DefaultValueHandling.Ignore)] - [InlineData(DefaultValueHandling.Include, true, "true", DefaultValueHandling.Include)] - [InlineData(DefaultValueHandling.Include, true, "unknown", null)] - [InlineData(DefaultValueHandling.Include, true, "", null)] - public async Task CheckBehaviorCombination(DefaultValueHandling? defaultValue, bool? allowQueryStringOverride, string queryStringValue, DefaultValueHandling? expected) - { - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var services = server.Host.Services; - var client = server.CreateClient(); - - var options = (JsonApiOptions)services.GetRequiredService(typeof(IJsonApiOptions)); - - if (defaultValue != null) - { - options.SerializerSettings.DefaultValueHandling = defaultValue.Value; - } - if (allowQueryStringOverride != null) - { - options.AllowQueryStringOverrideForSerializerDefaultValueHandling = allowQueryStringOverride.Value; - } - - var queryString = queryStringValue != null - ? $"&defaults={queryStringValue}" - : ""; - var route = $"/api/v1/todoItems/{_todoItem.Id}?include=owner{queryString}"; - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - - var isQueryStringValueEmpty = queryStringValue == string.Empty; - var isDisallowedOverride = !options.AllowQueryStringOverrideForSerializerDefaultValueHandling && queryStringValue != null; - var isQueryStringInvalid = queryStringValue != null && !bool.TryParse(queryStringValue, out _); - - if (isQueryStringValueEmpty) - { - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("Missing query string parameter value.", errorDocument.Errors[0].Title); - Assert.Equal("Missing value for 'defaults' query string parameter.", errorDocument.Errors[0].Detail); - Assert.Equal("defaults", errorDocument.Errors[0].Source.Parameter); - } - else if (isDisallowedOverride) - { - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("Usage of one or more query string parameters is not allowed at the requested endpoint.", errorDocument.Errors[0].Title); - Assert.Equal("The parameter 'defaults' cannot be used at this endpoint.", errorDocument.Errors[0].Detail); - Assert.Equal("defaults", errorDocument.Errors[0].Source.Parameter); - } - else if (isQueryStringInvalid) - { - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("The specified defaults is invalid.", errorDocument.Errors[0].Title); - Assert.Equal("The value 'unknown' must be 'true' or 'false'.", errorDocument.Errors[0].Detail); - Assert.Equal("defaults", errorDocument.Errors[0].Source.Parameter); - } - else - { - if (expected == null) - { - throw new Exception("Invalid test combination. Should never get here."); - } - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var deserializeBody = JsonConvert.DeserializeObject(body); - Assert.Equal(expected == DefaultValueHandling.Include, deserializeBody.SingleData.Attributes.ContainsKey("createdDate")); - Assert.Equal(expected == DefaultValueHandling.Include, deserializeBody.Included[0].Attributes.ContainsKey("the-Age")); - } - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreNullValuesTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreNullValuesTests.cs deleted file mode 100644 index e4ac1e61cc..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/IgnoreNullValuesTests.cs +++ /dev/null @@ -1,175 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Extensibility -{ - [Collection("WebHostCollection")] - public sealed class IgnoreNullValuesTests : IAsyncLifetime - { - private readonly AppDbContext _dbContext; - private readonly TodoItem _todoItem; - - public IgnoreNullValuesTests(TestFixture fixture) - { - _dbContext = fixture.GetRequiredService(); - _todoItem = new TodoItem - { - Description = null, - Ordinal = 1, - CreatedDate = new DateTime(2002, 2,2), - AchievedDate = new DateTime(2002, 2,4), - Owner = new Person { FirstName = "Bob", LastName = null } - }; - _dbContext.TodoItems.Add(_todoItem); - } - - public async Task InitializeAsync() - { - await _dbContext.SaveChangesAsync(); - } - - public Task DisposeAsync() - { - return Task.CompletedTask; - } - - [Theory] - [InlineData(null, null, null, NullValueHandling.Include)] - [InlineData(null, null, "false", NullValueHandling.Include)] - [InlineData(null, null, "true", NullValueHandling.Include)] - [InlineData(null, null, "unknown", null)] - [InlineData(null, null, "", null)] - [InlineData(null, false, null, NullValueHandling.Include)] - [InlineData(null, false, "false", NullValueHandling.Include)] - [InlineData(null, false, "true", NullValueHandling.Include)] - [InlineData(null, false, "unknown", null)] - [InlineData(null, false, "", null)] - [InlineData(null, true, null, NullValueHandling.Include)] - [InlineData(null, true, "false", NullValueHandling.Ignore)] - [InlineData(null, true, "true", NullValueHandling.Include)] - [InlineData(null, true, "unknown", null)] - [InlineData(null, true, "", null)] - [InlineData(NullValueHandling.Ignore, null, null, NullValueHandling.Ignore)] - [InlineData(NullValueHandling.Ignore, null, "false", NullValueHandling.Ignore)] - [InlineData(NullValueHandling.Ignore, null, "true", NullValueHandling.Ignore)] - [InlineData(NullValueHandling.Ignore, null, "unknown", null)] - [InlineData(NullValueHandling.Ignore, null, "", null)] - [InlineData(NullValueHandling.Ignore, false, null, NullValueHandling.Ignore)] - [InlineData(NullValueHandling.Ignore, false, "false", NullValueHandling.Ignore)] - [InlineData(NullValueHandling.Ignore, false, "true", NullValueHandling.Ignore)] - [InlineData(NullValueHandling.Ignore, false, "unknown", null)] - [InlineData(NullValueHandling.Ignore, false, "", null)] - [InlineData(NullValueHandling.Ignore, true, null, NullValueHandling.Ignore)] - [InlineData(NullValueHandling.Ignore, true, "false", NullValueHandling.Ignore)] - [InlineData(NullValueHandling.Ignore, true, "true", NullValueHandling.Include)] - [InlineData(NullValueHandling.Ignore, true, "unknown", null)] - [InlineData(NullValueHandling.Ignore, true, "", null)] - [InlineData(NullValueHandling.Include, null, null, NullValueHandling.Include)] - [InlineData(NullValueHandling.Include, null, "false", NullValueHandling.Include)] - [InlineData(NullValueHandling.Include, null, "true", NullValueHandling.Include)] - [InlineData(NullValueHandling.Include, null, "unknown", null)] - [InlineData(NullValueHandling.Include, null, "", null)] - [InlineData(NullValueHandling.Include, false, null, NullValueHandling.Include)] - [InlineData(NullValueHandling.Include, false, "false", NullValueHandling.Include)] - [InlineData(NullValueHandling.Include, false, "true", NullValueHandling.Include)] - [InlineData(NullValueHandling.Include, false, "unknown", null)] - [InlineData(NullValueHandling.Include, false, "", null)] - [InlineData(NullValueHandling.Include, true, null, NullValueHandling.Include)] - [InlineData(NullValueHandling.Include, true, "false", NullValueHandling.Ignore)] - [InlineData(NullValueHandling.Include, true, "true", NullValueHandling.Include)] - [InlineData(NullValueHandling.Include, true, "unknown", null)] - [InlineData(NullValueHandling.Include, true, "", null)] - public async Task CheckBehaviorCombination(NullValueHandling? defaultValue, bool? allowQueryStringOverride, string queryStringValue, NullValueHandling? expected) - { - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var services = server.Host.Services; - var client = server.CreateClient(); - - var options = (JsonApiOptions)services.GetRequiredService(typeof(IJsonApiOptions)); - - if (defaultValue != null) - { - options.SerializerSettings.NullValueHandling = defaultValue.Value; - } - if (allowQueryStringOverride != null) - { - options.AllowQueryStringOverrideForSerializerNullValueHandling = allowQueryStringOverride.Value; - } - - var queryString = queryStringValue != null - ? $"&nulls={queryStringValue}" - : ""; - var route = $"/api/v1/todoItems/{_todoItem.Id}?include=owner{queryString}"; - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - var body = await response.Content.ReadAsStringAsync(); - - var isQueryStringValueEmpty = queryStringValue == string.Empty; - var isDisallowedOverride = !options.AllowQueryStringOverrideForSerializerNullValueHandling && queryStringValue != null; - var isQueryStringInvalid = queryStringValue != null && !bool.TryParse(queryStringValue, out _); - - if (isQueryStringValueEmpty) - { - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("Missing query string parameter value.", errorDocument.Errors[0].Title); - Assert.Equal("Missing value for 'nulls' query string parameter.", errorDocument.Errors[0].Detail); - Assert.Equal("nulls", errorDocument.Errors[0].Source.Parameter); - } - else if (isDisallowedOverride) - { - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("Usage of one or more query string parameters is not allowed at the requested endpoint.", errorDocument.Errors[0].Title); - Assert.Equal("The parameter 'nulls' cannot be used at this endpoint.", errorDocument.Errors[0].Detail); - Assert.Equal("nulls", errorDocument.Errors[0].Source.Parameter); - } - else if (isQueryStringInvalid) - { - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("The specified nulls is invalid.", errorDocument.Errors[0].Title); - Assert.Equal("The value 'unknown' must be 'true' or 'false'.", errorDocument.Errors[0].Detail); - Assert.Equal("nulls", errorDocument.Errors[0].Source.Parameter); - } - else - { - if (expected == null) - { - throw new Exception("Invalid test combination. Should never get here."); - } - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var deserializeBody = JsonConvert.DeserializeObject(body); - Assert.Equal(expected == NullValueHandling.Include, deserializeBody.SingleData.Attributes.ContainsKey("description")); - Assert.Equal(expected == NullValueHandling.Include, deserializeBody.Included[0].Attributes.ContainsKey("lastName")); - } - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Appointment.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Appointment.cs new file mode 100644 index 0000000000..62838a6df8 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Appointment.cs @@ -0,0 +1,18 @@ +using System; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings +{ + public sealed class Appointment : Identifiable + { + [Attr] + public string Title { get; set; } + + [Attr] + public DateTimeOffset StartTime { get; set; } + + [Attr] + public DateTimeOffset EndTime { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Calendar.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Calendar.cs new file mode 100644 index 0000000000..5447aba40b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Calendar.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings +{ + public sealed class Calendar : Identifiable + { + [Attr] + public string TimeZone { get; set; } + + [Attr] + public int DefaultAppointmentDurationInMinutes { get; set; } + + [HasMany] + public ISet Appointments { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/CalendarsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/CalendarsController.cs new file mode 100644 index 0000000000..7aeed74bd0 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/CalendarsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings +{ + public sealed class CalendarsController : JsonApiController + { + public CalendarsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs new file mode 100644 index 0000000000..8c25b6efba --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings +{ + public sealed class QueryStringDbContext : DbContext + { + public DbSet Calendars { get; set; } + public DbSet Appointments { get; set; } + + public QueryStringDbContext(DbContextOptions options) : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringFakers.cs new file mode 100644 index 0000000000..3a14f8f64e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringFakers.cs @@ -0,0 +1,24 @@ +using System; +using Bogus; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings +{ + internal sealed class QueryStringFakers : FakerContainer + { + private readonly Lazy> _lazyCalendarFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(calendar => calendar.TimeZone, f => f.Date.TimeZoneString()) + .RuleFor(calendar => calendar.DefaultAppointmentDurationInMinutes, f => f.PickRandom(15, 30, 45, 60))); + + private readonly Lazy> _lazyAppointmentFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(appointment => appointment.Title, f => f.Random.Word()) + .RuleFor(appointment => appointment.StartTime, f => f.Date.FutureOffset()) + .RuleFor(appointment => appointment.EndTime, (f, appointment) => appointment.StartTime.AddHours(f.Random.Double(1, 4)))); + + public Faker Calendar => _lazyCalendarFaker.Value; + public Faker Appointment => _lazyAppointmentFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs index 6241847a31..2084a8dadb 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs @@ -3,18 +3,18 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; using Microsoft.Extensions.DependencyInjection; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings { - public sealed class QueryStringTests : IClassFixture> + public sealed class QueryStringTests + : IClassFixture, QueryStringDbContext>> { - private readonly IntegrationTestContext _testContext; + private readonly IntegrationTestContext, QueryStringDbContext> _testContext; + private readonly QueryStringFakers _fakers = new QueryStringFakers(); - public QueryStringTests(IntegrationTestContext testContext) + public QueryStringTests(IntegrationTestContext, QueryStringDbContext> testContext) { _testContext = testContext; } @@ -26,7 +26,7 @@ public async Task Cannot_use_unknown_query_string_parameter() var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); options.AllowUnknownQueryStringParameters = false; - var route = "/api/v1/articles?foo=bar"; + var route = "/calendars?foo=bar"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -48,7 +48,7 @@ public async Task Can_use_unknown_query_string_parameter() var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); options.AllowUnknownQueryStringParameters = true; - var route = "/api/v1/articles?foo=bar"; + var route = "/calendars?foo=bar"; // Act var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); @@ -71,7 +71,7 @@ public async Task Cannot_use_empty_query_string_parameter_value(string parameter var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); options.AllowUnknownQueryStringParameters = false; - var route = "/api/v1/articles?" + parameterName + "="; + var route = "calendars?" + parameterName + "="; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerDefaultValueHandlingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerDefaultValueHandlingTests.cs new file mode 100644 index 0000000000..ec9c5275eb --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerDefaultValueHandlingTests.cs @@ -0,0 +1,97 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings +{ + public sealed class SerializerDefaultValueHandlingTests + : IClassFixture, QueryStringDbContext>> + { + private readonly IntegrationTestContext, QueryStringDbContext> _testContext; + private readonly QueryStringFakers _fakers = new QueryStringFakers(); + + public SerializerDefaultValueHandlingTests(IntegrationTestContext, QueryStringDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Cannot_override_from_query_string() + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.AllowQueryStringOverrideForSerializerDefaultValueHandling = false; + + var route = "/calendars?defaults=true"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // 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 'defaults' cannot be used at this endpoint."); + responseDocument.Errors[0].Source.Parameter.Should().Be("defaults"); + } + + [Theory] + [InlineData(null, null, true)] + [InlineData(null, "false", false)] + [InlineData(null, "true", true)] + [InlineData(DefaultValueHandling.Ignore, null, false)] + [InlineData(DefaultValueHandling.Ignore, "false", false)] + [InlineData(DefaultValueHandling.Ignore, "true", true)] + [InlineData(DefaultValueHandling.Include, null, true)] + [InlineData(DefaultValueHandling.Include, "false", false)] + [InlineData(DefaultValueHandling.Include, "true", true)] + public async Task Can_override_from_query_string(DefaultValueHandling? configurationValue, string queryStringValue, bool expectInDocument) + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.AllowQueryStringOverrideForSerializerDefaultValueHandling = true; + options.SerializerSettings.DefaultValueHandling = configurationValue ?? DefaultValueHandling.Include; + + var calendar = _fakers.Calendar.Generate(); + calendar.DefaultAppointmentDurationInMinutes = default; + calendar.Appointments = _fakers.Appointment.Generate(1).ToHashSet(); + calendar.Appointments.Single().EndTime = default; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Calendars.Add(calendar); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/calendars/{calendar.StringId}?include=appointments" + (queryStringValue != null ? "&defaults=" + queryStringValue : ""); + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.Included.Should().HaveCount(1); + + if (expectInDocument) + { + responseDocument.SingleData.Attributes.Should().ContainKey("defaultAppointmentDurationInMinutes"); + responseDocument.Included[0].Attributes.Should().ContainKey("endTime"); + } + else + { + responseDocument.SingleData.Attributes.Should().NotContainKey("defaultAppointmentDurationInMinutes"); + responseDocument.Included[0].Attributes.Should().NotContainKey("endTime"); + } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerNullValueHandlingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerNullValueHandlingTests.cs new file mode 100644 index 0000000000..9487ae3b9e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerNullValueHandlingTests.cs @@ -0,0 +1,97 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings +{ + public sealed class SerializerNullValueHandlingTests + : IClassFixture, QueryStringDbContext>> + { + private readonly IntegrationTestContext, QueryStringDbContext> _testContext; + private readonly QueryStringFakers _fakers = new QueryStringFakers(); + + public SerializerNullValueHandlingTests(IntegrationTestContext, QueryStringDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Cannot_override_from_query_string() + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.AllowQueryStringOverrideForSerializerNullValueHandling = false; + + var route = "/calendars?nulls=true"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // 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 'nulls' cannot be used at this endpoint."); + responseDocument.Errors[0].Source.Parameter.Should().Be("nulls"); + } + + [Theory] + [InlineData(null, null, true)] + [InlineData(null, "false", false)] + [InlineData(null, "true", true)] + [InlineData(NullValueHandling.Ignore, null, false)] + [InlineData(NullValueHandling.Ignore, "false", false)] + [InlineData(NullValueHandling.Ignore, "true", true)] + [InlineData(NullValueHandling.Include, null, true)] + [InlineData(NullValueHandling.Include, "false", false)] + [InlineData(NullValueHandling.Include, "true", true)] + public async Task Can_override_from_query_string(NullValueHandling? configurationValue, string queryStringValue, bool expectInDocument) + { + // Arrange + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.AllowQueryStringOverrideForSerializerNullValueHandling = true; + options.SerializerSettings.NullValueHandling = configurationValue ?? NullValueHandling.Include; + + var calendar = _fakers.Calendar.Generate(); + calendar.TimeZone = null; + calendar.Appointments = _fakers.Appointment.Generate(1).ToHashSet(); + calendar.Appointments.Single().Title = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Calendars.Add(calendar); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/calendars/{calendar.StringId}?include=appointments" + (queryStringValue != null ? "&nulls=" + queryStringValue : ""); + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.Included.Should().HaveCount(1); + + if (expectInDocument) + { + responseDocument.SingleData.Attributes.Should().ContainKey("timeZone"); + responseDocument.Included[0].Attributes.Should().ContainKey("title"); + } + else + { + responseDocument.SingleData.Attributes.Should().NotContainKey("timeZone"); + responseDocument.Included[0].Attributes.Should().NotContainKey("title"); + } + } + } +} From 0c5d5e86d1dae19e816df464d8164cfc332b6505 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 27 Jan 2021 11:43:04 +0100 Subject: [PATCH 080/123] Refactored tests for DisableQueryStringAttribute --- .../Controllers/CountriesController.cs | 3 - .../Controllers/TagsController.cs | 2 - .../Startups/Startup.cs | 5 -- .../Spec/DisableQueryAttributeTests.cs | 67 --------------- .../BlockingHttpDeleteController.cs | 2 + .../BlockingWritesController.cs | 1 + .../DisableQueryStringTests.cs | 85 +++++++++++++++++++ .../SkipCacheQueryStringParameterReader.cs | 6 +- 8 files changed, 91 insertions(+), 80 deletions(-) delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs rename {src/Examples/JsonApiDotNetCoreExample/Services => test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers}/SkipCacheQueryStringParameterReader.cs (79%) diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/CountriesController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/CountriesController.cs index 2f37dcb387..25c875cdc9 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/CountriesController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/CountriesController.cs @@ -1,14 +1,11 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCoreExample.Controllers { - [DisableQueryString(StandardQueryStringParameters.Sort | StandardQueryStringParameters.Page)] public sealed class CountriesController : JsonApiController { public CountriesController( diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs index bccf2192d6..c06452cef7 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs @@ -1,13 +1,11 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Controllers.Annotations; using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCoreExample.Controllers { - [DisableQueryString("skipCache")] public sealed class TagsController : JsonApiController { public TagsController( diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs index 628e566a58..983f6a9dad 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs @@ -1,8 +1,6 @@ using System; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -28,9 +26,6 @@ public override void ConfigureServices(IServiceCollection services) { ConfigureClock(services); - services.AddScoped(); - services.AddScoped(sp => sp.GetRequiredService()); - services.AddDbContext(options => { options.EnableSensitiveDataLogging(); diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs deleted file mode 100644 index 42492f3abd..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DisableQueryAttributeTests.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - [Collection("WebHostCollection")] - public sealed class DisableQueryAttributeTests - { - private readonly TestFixture _fixture; - - public DisableQueryAttributeTests(TestFixture fixture) - { - _fixture = fixture; - } - - [Fact] - public async Task Cannot_Sort_If_Query_String_Parameter_Is_Blocked_By_Controller() - { - // Arrange - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/countries?sort=name"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("Usage of one or more query string parameters is not allowed at the requested endpoint.", errorDocument.Errors[0].Title); - Assert.Equal("The parameter 'sort' cannot be used at this endpoint.", errorDocument.Errors[0].Detail); - Assert.Equal("sort", errorDocument.Errors[0].Source.Parameter); - } - - [Fact] - public async Task Cannot_Use_Custom_Query_String_Parameter_If_Blocked_By_Controller() - { - // Arrange - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/tags?skipCache=true"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.BadRequest, errorDocument.Errors[0].StatusCode); - Assert.Equal("Usage of one or more query string parameters is not allowed at the requested endpoint.", errorDocument.Errors[0].Title); - Assert.Equal("The parameter 'skipCache' cannot be used at this endpoint.", errorDocument.Errors[0].Detail); - Assert.Equal("skipCache", errorDocument.Errors[0].Source.Parameter); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingHttpDeleteController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingHttpDeleteController.cs index 9651950dd3..39a3eea31b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingHttpDeleteController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingHttpDeleteController.cs @@ -1,12 +1,14 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers { [NoHttpDelete] + [DisableQueryString(StandardQueryStringParameters.Sort | StandardQueryStringParameters.Page)] public sealed class BlockingHttpDeleteController : JsonApiController { public BlockingHttpDeleteController(IJsonApiOptions options, ILoggerFactory loggerFactory, diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingWritesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingWritesController.cs index 5704bb5600..21602e5487 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingWritesController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingWritesController.cs @@ -7,6 +7,7 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers { [HttpReadOnly] + [DisableQueryString("skipCache")] public sealed class BlockingWritesController : JsonApiController { public BlockingWritesController(IJsonApiOptions options, ILoggerFactory loggerFactory, diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs new file mode 100644 index 0000000000..15e3987762 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs @@ -0,0 +1,85 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers +{ + public sealed class DisableQueryStringTests + : IClassFixture, RestrictionDbContext>> + { + private readonly IntegrationTestContext, RestrictionDbContext> _testContext; + private readonly RestrictionFakers _fakers = new RestrictionFakers(); + + public DisableQueryStringTests(IntegrationTestContext, RestrictionDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddScoped(); + services.AddScoped(sp => sp.GetRequiredService()); + }); + } + + [Fact] + public async Task Cannot_sort_if_query_string_parameter_is_blocked_by_controller() + { + // Arrange + var route = "/sofas?sort=id"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // 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_paginate_if_query_string_parameter_is_blocked_by_controller() + { + // Arrange + var route = "/sofas?page[number]=2"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // 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_custom_query_string_parameter_if_blocked_by_controller() + { + // Arrange + var route = "/beds?skipCache=true"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // 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 'skipCache' cannot be used at this endpoint."); + responseDocument.Errors[0].Source.Parameter.Should().Be("skipCache"); + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/SkipCacheQueryStringParameterReader.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/SkipCacheQueryStringParameterReader.cs similarity index 79% rename from src/Examples/JsonApiDotNetCoreExample/Services/SkipCacheQueryStringParameterReader.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/SkipCacheQueryStringParameterReader.cs index 14db454c6b..a165357740 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Services/SkipCacheQueryStringParameterReader.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/SkipCacheQueryStringParameterReader.cs @@ -4,9 +4,9 @@ using JsonApiDotNetCore.QueryStrings; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCoreExample.Services +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers { - public class SkipCacheQueryStringParameterReader : IQueryStringParameterReader + public sealed class SkipCacheQueryStringParameterReader : IQueryStringParameterReader { private const string _skipCacheParameterName = "skipCache"; @@ -27,7 +27,7 @@ public void Read(string parameterName, StringValues parameterValue) if (!bool.TryParse(parameterValue, out bool skipCache)) { throw new InvalidQueryStringParameterException(parameterName, "Boolean value required.", - $"The value {parameterValue} is not a valid boolean."); + $"The value '{parameterValue}' is not a valid boolean."); } SkipCache = skipCache; From 7057c89aa2f97d59adf2bdffc6a731df3b2ed422 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 28 Jan 2021 11:54:14 +0100 Subject: [PATCH 081/123] Refactored tests for resource injection --- .../Acceptance/InjectableResourceTests.cs | 209 ---------- .../FrozenSystemClock.cs | 13 + .../GiftCertificate.cs | 28 ++ .../GiftCertificatesController.cs | 16 + .../InjectionDbContext.cs | 20 + .../InjectionFakers.cs | 40 ++ .../PostOffice.cs | 35 ++ .../PostOfficesController.cs | 16 + .../ResourceInjectionTests.cs | 360 ++++++++++++++++++ 9 files changed, 528 insertions(+), 209 deletions(-) delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/FrozenSystemClock.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/GiftCertificate.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/GiftCertificatesController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/InjectionDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/InjectionFakers.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/PostOffice.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/PostOfficesController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs deleted file mode 100644 index 6388be509f..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/InjectableResourceTests.cs +++ /dev/null @@ -1,209 +0,0 @@ -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Newtonsoft.Json; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - [Collection("WebHostCollection")] - public class InjectableResourceTests - { - private readonly TestFixture _fixture; - private readonly AppDbContext _context; - private readonly Faker _personFaker; - private readonly Faker _passportFaker; - private readonly Faker _countryFaker; - - public InjectableResourceTests(TestFixture fixture) - { - _fixture = fixture; - _context = fixture.GetRequiredService(); - - _personFaker = new Faker() - .RuleFor(t => t.FirstName, f => f.Name.FirstName()) - .RuleFor(t => t.LastName, f => f.Name.LastName()); - _passportFaker = new Faker() - .CustomInstantiator(f => new Passport(_context)) - .RuleFor(t => t.SocialSecurityNumber, f => f.Random.Number(100, 10_000)); - _countryFaker = new Faker() - .RuleFor(c => c.Name, f => f.Address.Country()); - } - - [Fact] - public async Task Can_Get_Single_Passport() - { - // Arrange - var passport = _passportFaker.Generate(); - passport.BirthCountry = _countryFaker.Generate(); - - _context.Passports.Add(passport); - await _context.SaveChangesAsync(); - - var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/passports/" + passport.StringId); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - - var body = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(body); - - Assert.NotNull(document.SingleData); - Assert.Equal(passport.IsLocked, document.SingleData.Attributes["isLocked"]); - } - - [Fact] - public async Task Can_Get_Passports() - { - // Arrange - await _context.ClearTableAsync(); - - var passports = _passportFaker.Generate(3); - foreach (var passport in passports) - { - passport.BirthCountry = _countryFaker.Generate(); - } - - _context.Passports.AddRange(passports); - await _context.SaveChangesAsync(); - - var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/passports"); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - - var body = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(body); - - Assert.Equal(3, document.ManyData.Count); - foreach (var passport in passports) - { - Assert.Contains(document.ManyData, - resource => (long)resource.Attributes["socialSecurityNumber"] == passport.SocialSecurityNumber); - Assert.Contains(document.ManyData, - resource => (string)resource.Attributes["birthCountryName"] == passport.BirthCountryName); - } - } - - [Fact] - public async Task Can_Get_Passports_With_Filter() - { - // Arrange - await _context.ClearTableAsync(); - - var passports = _passportFaker.Generate(3); - foreach (var passport in passports) - { - passport.SocialSecurityNumber = 11111; - passport.BirthCountry = _countryFaker.Generate(); - passport.Person = _personFaker.Generate(); - passport.Person.FirstName = "Jack"; - } - - passports[2].SocialSecurityNumber = 12345; - passports[2].Person.FirstName= "Joe"; - - _context.Passports.AddRange(passports); - await _context.SaveChangesAsync(); - - var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/passports?include=person&filter=and(equals(socialSecurityNumber,'12345'),equals(person.firstName,'Joe'))"); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - - var body = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(body); - - Assert.Single(document.ManyData); - Assert.Equal(12345L, document.ManyData[0].Attributes["socialSecurityNumber"]); - - Assert.Single(document.Included); - Assert.Equal("Joe", document.Included[0].Attributes["firstName"]); - } - - [Fact] - public async Task Can_Get_Passports_With_Sparse_Fieldset() - { - // Arrange - await _context.ClearTableAsync(); - - var passports = _passportFaker.Generate(2); - foreach (var passport in passports) - { - passport.BirthCountry = _countryFaker.Generate(); - passport.Person = _personFaker.Generate(); - } - - _context.Passports.AddRange(passports); - await _context.SaveChangesAsync(); - - var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/passports?include=person&fields[passports]=socialSecurityNumber&fields[people]=firstName"); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - _fixture.AssertEqualStatusCode(HttpStatusCode.OK, response); - - var body = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(body); - - Assert.Equal(2, document.ManyData.Count); - foreach (var passport in passports) - { - Assert.Contains(document.ManyData, - resource => (long)resource.Attributes["socialSecurityNumber"] == passport.SocialSecurityNumber); - } - - Assert.DoesNotContain(document.ManyData, - resource => resource.Attributes.ContainsKey("isLocked")); - - Assert.Equal(2, document.Included.Count); - foreach (var person in passports.Select(p => p.Person)) - { - Assert.Contains(document.Included, - resource => (string) resource.Attributes["firstName"] == person.FirstName); - } - - Assert.DoesNotContain(document.Included, - resource => resource.Attributes.ContainsKey("lastName")); - } - - [Fact] - public async Task Fail_When_Deleting_Missing_Passport() - { - // Arrange - - var request = new HttpRequestMessage(HttpMethod.Delete, "/api/v1/passports/1234567890"); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - _fixture.AssertEqualStatusCode(HttpStatusCode.NotFound, response); - - var body = await response.Content.ReadAsStringAsync(); - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("The requested resource does not exist.", errorDocument.Errors[0].Title); - Assert.Equal("Resource of type 'passports' with ID '1234567890' does not exist.", errorDocument.Errors[0].Detail); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/FrozenSystemClock.cs b/test/JsonApiDotNetCoreExampleTests/FrozenSystemClock.cs new file mode 100644 index 0000000000..f08b81b905 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/FrozenSystemClock.cs @@ -0,0 +1,13 @@ +using System; +using Microsoft.AspNetCore.Authentication; + +namespace JsonApiDotNetCoreExampleTests +{ + internal sealed class FrozenSystemClock : ISystemClock + { + private static readonly DateTimeOffset _defaultTime = + new DateTimeOffset(new DateTime(2000, 1, 1, 1, 1, 1), TimeSpan.FromHours(1)); + + public DateTimeOffset UtcNow { get; set; } = _defaultTime; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/GiftCertificate.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/GiftCertificate.cs new file mode 100644 index 0000000000..44436e03ee --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/GiftCertificate.cs @@ -0,0 +1,28 @@ +using System; +using System.ComponentModel.DataAnnotations.Schema; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.AspNetCore.Authentication; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceConstructorInjection +{ + public sealed class GiftCertificate : Identifiable + { + private readonly ISystemClock _systemClock; + + [Attr] + public DateTimeOffset IssueDate { get; set; } + + [Attr(Capabilities = AttrCapabilities.AllowView)] + [NotMapped] + public bool HasExpired => IssueDate.AddYears(1) < _systemClock.UtcNow; + + [HasOne] + public PostOffice Issuer { get; set; } + + public GiftCertificate(InjectionDbContext injectionDbContext) + { + _systemClock = injectionDbContext.SystemClock; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/GiftCertificatesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/GiftCertificatesController.cs new file mode 100644 index 0000000000..8d4b5c1e39 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/GiftCertificatesController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceConstructorInjection +{ + public sealed class GiftCertificatesController : JsonApiController + { + public GiftCertificatesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/InjectionDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/InjectionDbContext.cs new file mode 100644 index 0000000000..2b695dfd45 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/InjectionDbContext.cs @@ -0,0 +1,20 @@ +using System; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceConstructorInjection +{ + public sealed class InjectionDbContext : DbContext + { + public ISystemClock SystemClock { get; } + + public DbSet PostOffice { get; set; } + public DbSet GiftCertificates { get; set; } + + public InjectionDbContext(DbContextOptions options, ISystemClock systemClock) + : base(options) + { + SystemClock = systemClock ?? throw new ArgumentNullException(nameof(systemClock)); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/InjectionFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/InjectionFakers.cs new file mode 100644 index 0000000000..6dbf85a9c3 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/InjectionFakers.cs @@ -0,0 +1,40 @@ +using System; +using Bogus; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceConstructorInjection +{ + internal sealed class InjectionFakers : FakerContainer + { + private readonly IServiceProvider _serviceProvider; + + private readonly Lazy> _lazyPostOfficeFaker; + private readonly Lazy> _lazyGiftCertificateFaker; + + public Faker PostOffice => _lazyPostOfficeFaker.Value; + public Faker GiftCertificate => _lazyGiftCertificateFaker.Value; + + public InjectionFakers(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + + _lazyPostOfficeFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .CustomInstantiator(f => new PostOffice(ResolveDbContext())) + .RuleFor(postOffice => postOffice.Address, f => f.Address.FullAddress())); + + _lazyGiftCertificateFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .CustomInstantiator(f => new GiftCertificate(ResolveDbContext())) + .RuleFor(giftCertificate => giftCertificate.IssueDate, f => f.Date.PastOffset())); + } + + private InjectionDbContext ResolveDbContext() + { + using var scope = _serviceProvider.CreateScope(); + return scope.ServiceProvider.GetRequiredService(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/PostOffice.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/PostOffice.cs new file mode 100644 index 0000000000..e183760098 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/PostOffice.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.AspNetCore.Authentication; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceConstructorInjection +{ + public sealed class PostOffice : Identifiable + { + private readonly ISystemClock _systemClock; + + [Attr] + public string Address { get; set; } + + [Attr(Capabilities = AttrCapabilities.AllowView)] + [NotMapped] + public bool IsOpen => IsWithinOperatingHours(); + + [HasMany] + public IList GiftCertificates { get; set; } + + public PostOffice(InjectionDbContext injectionDbContext) + { + _systemClock = injectionDbContext.SystemClock; + } + + private bool IsWithinOperatingHours() + { + var currentTime = _systemClock.UtcNow; + return currentTime.DayOfWeek >= DayOfWeek.Monday && currentTime.DayOfWeek <= DayOfWeek.Friday && currentTime.Hour >= 9 && currentTime.Hour <= 17; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/PostOfficesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/PostOfficesController.cs new file mode 100644 index 0000000000..aff0d117c3 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/PostOfficesController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceConstructorInjection +{ + public sealed class PostOfficesController : JsonApiController + { + public PostOfficesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs new file mode 100644 index 0000000000..449c9f2ddb --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs @@ -0,0 +1,360 @@ +using System; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Extensions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceConstructorInjection +{ + public sealed class ResourceInjectionTests + : IClassFixture, InjectionDbContext>> + { + private readonly IntegrationTestContext, InjectionDbContext> _testContext; + private readonly InjectionFakers _fakers; + + public ResourceInjectionTests(IntegrationTestContext, InjectionDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesBeforeStartup(services => + { + services.AddSingleton(); + }); + + _fakers = new InjectionFakers(testContext.Factory.Services); + } + + [Fact] + public async Task Can_get_resource_by_ID() + { + // Arrange + var clock = (FrozenSystemClock) _testContext.Factory.Services.GetRequiredService(); + clock.UtcNow = 27.January(2021); + + var certificate = _fakers.GiftCertificate.Generate(); + certificate.IssueDate = 28.January(2020); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.GiftCertificates.Add(certificate); + await dbContext.SaveChangesAsync(); + }); + + var route = "/giftCertificates/" + certificate.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(certificate.StringId); + responseDocument.SingleData.Attributes["issueDate"].Should().Be(certificate.IssueDate.DateTime); + responseDocument.SingleData.Attributes["hasExpired"].Should().Be(false); + } + + [Fact] + public async Task Can_filter_resources_by_ID() + { + // Arrange + var clock = (FrozenSystemClock) _testContext.Factory.Services.GetRequiredService(); + clock.UtcNow = 27.January(2021).At(13, 53); + + var postOffices = _fakers.PostOffice.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.PostOffice.AddRange(postOffices); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/postOffices?filter=equals(id,'{postOffices[1].StringId}')"; + + // 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(postOffices[1].StringId); + responseDocument.ManyData[0].Attributes["address"].Should().Be(postOffices[1].Address); + responseDocument.ManyData[0].Attributes["isOpen"].Should().Be(true); + } + + [Fact] + public async Task Can_get_secondary_resource_with_fieldset() + { + // Arrange + var clock = (FrozenSystemClock) _testContext.Factory.Services.GetRequiredService(); + clock.UtcNow = 27.January(2021).At(13, 53); + + var certificate = _fakers.GiftCertificate.Generate(); + certificate.Issuer = _fakers.PostOffice.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.GiftCertificates.Add(certificate); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/giftCertificates/{certificate.StringId}/issuer?fields[postOffices]=id,isOpen"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(certificate.Issuer.StringId); + responseDocument.SingleData.Attributes.Should().HaveCount(1); + responseDocument.SingleData.Attributes["isOpen"].Should().Be(true); + } + + [Fact] + public async Task Can_create_resource_with_ToOne_relationship_and_include() + { + // Arrange + var clock = (FrozenSystemClock) _testContext.Factory.Services.GetRequiredService(); + clock.UtcNow = 19.March(1998).At(6, 34); + + var existingOffice = _fakers.PostOffice.Generate(); + + var newIssueDate = 18.March(1997); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PostOffice.Add(existingOffice); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "giftCertificates", + attributes = new + { + issueDate = newIssueDate + }, + relationships = new + { + issuer = new + { + data = new + { + type = "postOffices", + id = existingOffice.StringId + } + } + } + } + }; + + var route = "/giftCertificates?include=issuer"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["issueDate"].Should().Be(newIssueDate); + responseDocument.SingleData.Attributes["hasExpired"].Should().Be(true); + responseDocument.SingleData.Relationships["issuer"].SingleData.Id.Should().Be(existingOffice.StringId); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Id.Should().Be(existingOffice.StringId); + responseDocument.Included[0].Attributes["address"].Should().Be(existingOffice.Address); + responseDocument.Included[0].Attributes["isOpen"].Should().Be(false); + + var newCertificateId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var certificateInDatabase = await dbContext.GiftCertificates + .Include(giftCertificate => giftCertificate.Issuer) + .FirstAsync(giftCertificate => giftCertificate.Id == newCertificateId); + + certificateInDatabase.IssueDate.Should().Be(newIssueDate); + + certificateInDatabase.Issuer.Should().NotBeNull(); + certificateInDatabase.Issuer.Id.Should().Be(existingOffice.Id); + }); + } + + [Fact] + public async Task Can_update_resource_with_ToMany_relationship() + { + // Arrange + var clock = (FrozenSystemClock) _testContext.Factory.Services.GetRequiredService(); + clock.UtcNow = 19.March(1998).At(6, 34); + + var existingOffice = _fakers.PostOffice.Generate(); + existingOffice.GiftCertificates = _fakers.GiftCertificate.Generate(1); + + var newAddress = _fakers.PostOffice.Generate().Address; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PostOffice.Add(existingOffice); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "postOffices", + id = existingOffice.StringId, + attributes = new + { + address = newAddress + }, + relationships = new + { + giftCertificates = new + { + data = new[] + { + new + { + type = "giftCertificates", + id = existingOffice.GiftCertificates[0].StringId + } + } + } + } + } + }; + + var route = "/postOffices/" + existingOffice.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var officeInDatabase = await dbContext.PostOffice + .Include(postOffice => postOffice.GiftCertificates) + .FirstAsync(postOffice => postOffice.Id == existingOffice.Id); + + officeInDatabase.Address.Should().Be(newAddress); + + officeInDatabase.GiftCertificates.Should().HaveCount(1); + officeInDatabase.GiftCertificates[0].Id.Should().Be(existingOffice.GiftCertificates[0].Id); + }); + } + + [Fact] + public async Task Can_delete_resource() + { + // Arrange + var existingOffice = _fakers.PostOffice.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PostOffice.Add(existingOffice); + await dbContext.SaveChangesAsync(); + }); + + var route = "/postOffices/" + existingOffice.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var officeInDatabase = await dbContext.PostOffice + .FirstOrDefaultAsync(postOffice => postOffice.Id == existingOffice.Id); + + officeInDatabase.Should().BeNull(); + }); + } + + [Fact] + public async Task Cannot_delete_unknown_resource() + { + // Arrange + var route = "/postOffices/99999999"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // 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 'postOffices' with ID '99999999' does not exist."); + } + + [Fact] + public async Task Can_add_to_ToMany_relationship() + { + // Arrange + var existingOffice = _fakers.PostOffice.Generate(); + existingOffice.GiftCertificates = _fakers.GiftCertificate.Generate(1); + + var existingCertificate = _fakers.GiftCertificate.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingOffice, existingCertificate); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "giftCertificates", + id = existingCertificate.StringId + } + } + }; + + var route = $"/postOffices/{existingOffice.StringId}/relationships/giftCertificates"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var officeInDatabase = await dbContext.PostOffice + .Include(postOffice => postOffice.GiftCertificates) + .FirstAsync(postOffice => postOffice.Id == existingOffice.Id); + + officeInDatabase.GiftCertificates.Should().HaveCount(2); + }); + } + } +} From 8756f2555031c11e130c49b9a78b39022780dd1f Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 28 Jan 2021 13:29:33 +0100 Subject: [PATCH 082/123] Fixed assertions on DateTime/DateTimeOffset in tests --- .../IntegrationTestConfiguration.cs | 19 +++++++++++++++ .../IntegrationTestContext.cs | 3 ++- .../Filtering/FilterDataTypeTests.cs | 11 +++++---- .../Filtering/FilterOperatorTests.cs | 2 +- .../IntegrationTests/Includes/IncludeTests.cs | 4 ++-- .../NamingConventions/KebabCasingTests.cs | 2 +- .../ObjectAssertionsExtensions.cs | 23 +++++++------------ .../ResourceInjectionTests.cs | 9 ++++---- .../SparseFieldSets/SparseFieldSetTests.cs | 2 +- 9 files changed, 44 insertions(+), 31 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTestConfiguration.cs diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTestConfiguration.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTestConfiguration.cs new file mode 100644 index 0000000000..7f92b3ee67 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTestConfiguration.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +namespace JsonApiDotNetCoreExampleTests +{ + internal static class IntegrationTestConfiguration + { + // Because our tests often deserialize incoming responses into weakly-typed string-to-object dictionaries (as part of ResourceObject), + // Newtonsoft.JSON is unable to infer the target type in such cases. So we steer a bit using explicit configuration. + public static readonly JsonSerializerSettings DeserializationSettings = new JsonSerializerSettings + { + // Choosing between DateTime and DateTimeOffset is impossible: it depends on how the resource properties are declared. + // So instead we leave them as strings and let the test itself deal with the conversion. + DateParseHandling = DateParseHandling.None, + + // Here we must choose between double (default) and decimal. Favored decimal because it has higher precision (but lower range). + FloatParseHandling = FloatParseHandling.Decimal + }; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs index cb12fc2c09..ea0f41f279 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs @@ -189,7 +189,8 @@ private TResponseDocument DeserializeResponse(string response try { - return JsonConvert.DeserializeObject(responseText); + return JsonConvert.DeserializeObject(responseText, + IntegrationTestConfiguration.DeserializationSettings); } catch (JsonException exception) { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDataTypeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDataTypeTests.cs index 90aa2dd639..d3f062b99a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDataTypeTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDataTypeTests.cs @@ -2,6 +2,7 @@ using System.Net; using System.Threading.Tasks; using FluentAssertions; +using FluentAssertions.Common; using FluentAssertions.Extensions; using Humanizer; using JsonApiDotNetCore.Configuration; @@ -138,7 +139,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Attributes["someDateTime"].Should().Be(resource.SomeDateTime); + responseDocument.ManyData[0].Attributes["someDateTime"].Should().BeCloseTo(resource.SomeDateTime); } [Fact] @@ -147,7 +148,7 @@ public async Task Can_filter_equality_on_type_DateTimeOffset() // Arrange var resource = new FilterableResource { - SomeDateTimeOffset = new DateTimeOffset(27.January(2003).At(11, 22, 33, 44), TimeSpan.FromHours(3)) + SomeDateTimeOffset = 27.January(2003).At(11, 22, 33, 44).ToDateTimeOffset(TimeSpan.FromHours(3)) }; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -167,7 +168,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Attributes["someDateTimeOffset"].Should().Be(resource.SomeDateTimeOffset.LocalDateTime); + responseDocument.ManyData[0].Attributes["someDateTimeOffset"].Should().BeCloseTo(resource.SomeDateTimeOffset); } [Fact] @@ -254,7 +255,7 @@ public async Task Can_filter_is_null_on_type(string propertyName) SomeNullableDouble = 1, SomeNullableGuid = Guid.NewGuid(), SomeNullableDateTime = 1.January(2001), - SomeNullableDateTimeOffset = 1.January(2001), + SomeNullableDateTimeOffset = 1.January(2001).ToDateTimeOffset(TimeSpan.FromHours(-1)), SomeNullableTimeSpan = TimeSpan.FromHours(1), SomeNullableEnum = DayOfWeek.Friday }; @@ -305,7 +306,7 @@ public async Task Can_filter_is_not_null_on_type(string propertyName) SomeNullableDouble = 1, SomeNullableGuid = Guid.NewGuid(), SomeNullableDateTime = 1.January(2001), - SomeNullableDateTimeOffset = 1.January(2001), + SomeNullableDateTimeOffset = 1.January(2001).ToDateTimeOffset(TimeSpan.FromHours(-1)), SomeNullableTimeSpan = TimeSpan.FromHours(1), SomeNullableEnum = DayOfWeek.Friday }; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs index 69254c0632..fb38aeee8d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs @@ -381,7 +381,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Attributes["someDateTime"].Should().Be(resource.SomeDateTime); + responseDocument.ManyData[0].Attributes["someDateTime"].Should().BeCloseTo(resource.SomeDateTime); } [Theory] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs index 00a281ff6f..f97e5e6a1c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs @@ -282,7 +282,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Type.Should().Be("revisions"); responseDocument.Included[0].Id.Should().Be(article.Revisions.Single().StringId); - responseDocument.Included[0].Attributes["publishTime"].Should().Be(article.Revisions.Single().PublishTime); + responseDocument.Included[0].Attributes["publishTime"].Should().BeCloseTo(article.Revisions.Single().PublishTime); } [Fact] @@ -479,7 +479,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[1].Type.Should().Be("revisions"); responseDocument.Included[1].Id.Should().Be(blog.Articles[0].Revisions.Single().StringId); - responseDocument.Included[1].Attributes["publishTime"].Should().Be(blog.Articles[0].Revisions.Single().PublishTime); + responseDocument.Included[1].Attributes["publishTime"].Should().BeCloseTo(blog.Articles[0].Revisions.Single().PublishTime); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs index 6b5929328b..20bda54a9f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs @@ -50,7 +50,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Type.Should().Be("diving-boards"); responseDocument.Included[0].Id.Should().Be(pools[1].DivingBoards[0].StringId); - responseDocument.Included[0].Attributes["height-in-meters"].Should().BeApproximately(pools[1].DivingBoards[0].HeightInMeters); + responseDocument.Included[0].Attributes["height-in-meters"].As().Should().BeApproximately(pools[1].DivingBoards[0].HeightInMeters, 0.00000000001M); responseDocument.Included[0].Relationships.Should().BeNull(); responseDocument.Included[0].Links.Self.Should().Be($"/public-api/diving-boards/{pools[1].DivingBoards[0].StringId}"); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs index 159a7691b4..d342daf992 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs @@ -7,7 +7,9 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests public static class ObjectAssertionsExtensions { /// - /// Used to assert on a nullable column, whose value is returned as in JSON:API response body. + /// Used to assert on a (nullable) or property, + /// whose value is returned as in JSON:API response body + /// because of . /// public static void BeCloseTo(this ObjectAssertions source, DateTimeOffset? expected, string because = "", params object[] becauseArgs) @@ -18,23 +20,14 @@ public static void BeCloseTo(this ObjectAssertions source, DateTimeOffset? expec } else { - // We lose a little bit of precision (milliseconds) on roundtrip through PostgreSQL database. + if (!DateTimeOffset.TryParse((string) source.Subject, out var value)) + { + source.Subject.Should().Be(expected, because, becauseArgs); + } - var value = new DateTimeOffset((DateTime) source.Subject); + // We lose a little bit of precision (milliseconds) on roundtrip through PostgreSQL database. value.Should().BeCloseTo(expected.Value, because: because, becauseArgs: becauseArgs); } } - - /// - /// Used to assert on a column, whose value is returned as in json:api response body. - /// - public static void BeApproximately(this ObjectAssertions source, decimal? expected, decimal precision = 0.00000000001M, string because = "", - params object[] becauseArgs) - { - // We lose a little bit of precision on roundtrip through PostgreSQL database. - - var value = (decimal?) (double) source.Subject; - value.Should().BeApproximately(expected, precision, because, becauseArgs); - } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs index 449c9f2ddb..17b3262114 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs @@ -1,8 +1,7 @@ -using System; -using System.Linq; using System.Net; using System.Threading.Tasks; using FluentAssertions; +using FluentAssertions.Common; using FluentAssertions.Extensions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Authentication; @@ -56,7 +55,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.SingleData.Should().NotBeNull(); responseDocument.SingleData.Id.Should().Be(certificate.StringId); - responseDocument.SingleData.Attributes["issueDate"].Should().Be(certificate.IssueDate.DateTime); + responseDocument.SingleData.Attributes["issueDate"].Should().BeCloseTo(certificate.IssueDate); responseDocument.SingleData.Attributes["hasExpired"].Should().Be(false); } @@ -129,7 +128,7 @@ public async Task Can_create_resource_with_ToOne_relationship_and_include() var existingOffice = _fakers.PostOffice.Generate(); - var newIssueDate = 18.March(1997); + var newIssueDate = 18.March(1997).ToDateTimeOffset(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -169,7 +168,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["issueDate"].Should().Be(newIssueDate); + responseDocument.SingleData.Attributes["issueDate"].Should().BeCloseTo(newIssueDate); responseDocument.SingleData.Attributes["hasExpired"].Should().Be(true); responseDocument.SingleData.Relationships["issuer"].SingleData.Id.Should().Be(existingOffice.StringId); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs index 54f43380e0..029ffe87ac 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs @@ -641,7 +641,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[0].Id.Should().Be(blog.Owner.StringId); responseDocument.Included[0].Attributes["firstName"].Should().Be(blog.Owner.FirstName); responseDocument.Included[0].Attributes["lastName"].Should().Be(blog.Owner.LastName); - responseDocument.Included[0].Attributes["dateOfBirth"].Should().Be(blog.Owner.DateOfBirth); + responseDocument.Included[0].Attributes["dateOfBirth"].Should().BeCloseTo(blog.Owner.DateOfBirth); responseDocument.Included[0].Relationships["articles"].ManyData.Should().HaveCount(1); responseDocument.Included[0].Relationships["articles"].ManyData[0].Id.Should().Be(blog.Owner.Articles[0].StringId); responseDocument.Included[0].Relationships["articles"].Links.Self.Should().NotBeNull(); From 44653f8f6ee428ea9e88613e0ee74355910cabf0 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 28 Jan 2021 17:11:36 +0100 Subject: [PATCH 083/123] Refactored tests for non-json:api controllers --- .../Controllers/NonJsonApiController.cs | 51 ++++++ .../Controllers/TestValuesController.cs | 35 ---- .../Extensibility/CustomControllerTests.cs | 20 --- .../Acceptance/NonJsonApiControllerTests.cs | 104 ------------ .../NonJsonApiControllerTests.cs | 153 ++++++++++++++++++ 5 files changed, 204 insertions(+), 159 deletions(-) create mode 100644 src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Controllers/TestValuesController.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/NonJsonApiControllerTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs new file mode 100644 index 0000000000..90a864f07c --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs @@ -0,0 +1,51 @@ +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + +namespace JsonApiDotNetCoreExample.Controllers +{ + [Route("[controller]")] + public sealed class NonJsonApiController : ControllerBase + { + [HttpGet] + public IActionResult Get() + { + var result = new[] {"Welcome!"}; + return Ok(result); + } + + [HttpPost] + public async Task PostAsync() + { + string name = await new StreamReader(Request.Body).ReadToEndAsync(); + + if (string.IsNullOrEmpty(name)) + { + return BadRequest("Please send your name."); + } + + var result = "Hello, " + name; + return Ok(result); + } + + [HttpPut] + public IActionResult Put([FromBody] string name) + { + var result = "Hi, " + name; + return Ok(result); + } + + [HttpPatch] + public IActionResult Patch(string name) + { + var result = "Good day, " + name; + return Ok(result); + } + + [HttpDelete] + public IActionResult Delete() + { + return Ok("Bye."); + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TestValuesController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TestValuesController.cs deleted file mode 100644 index 9487df1c3b..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TestValuesController.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace JsonApiDotNetCoreExample.Controllers -{ - [Route("[controller]")] - public class TestValuesController : ControllerBase - { - [HttpGet] - public IActionResult Get() - { - var result = new[] { "value" }; - return Ok(result); - } - - [HttpPost] - public IActionResult Post(string name) - { - var result = "Hello, " + name; - return Ok(result); - } - - [HttpPatch] - public IActionResult Patch(string name) - { - var result = "Hello, " + name; - return Ok(result); - } - - [HttpDelete] - public IActionResult Delete() - { - return Ok("Deleted"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs index 86c021e86d..61c467cbdc 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs @@ -37,26 +37,6 @@ public CustomControllerTests(TestFixture fixture) .RuleFor(p => p.LastName, f => f.Name.LastName()); } - [Fact] - public async Task NonJsonApiControllers_DoNotUse_Dasherized_Routes() - { - // Arrange - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - var route = "testValues"; - - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - [Fact] public async Task CustomRouteControllers_Uses_Dasherized_Collection_Route() { diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/NonJsonApiControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/NonJsonApiControllerTests.cs deleted file mode 100644 index 397914ba7b..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/NonJsonApiControllerTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - public sealed class NonJsonApiControllerTests - { - [Fact] - public async Task NonJsonApiController_Skips_Middleware_And_Formatters_On_Get() - { - // Arrange - const string route = "testValues"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Get, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType.ToString()); - - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal("[\"value\"]", body); - } - - [Fact] - public async Task NonJsonApiController_Skips_Middleware_And_Formatters_On_Post() - { - // Arrange - const string route = "testValues?name=Jack"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Post, route) {Content = new StringContent("XXX")}; - request.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("text/plain; charset=utf-8", response.Content.Headers.ContentType.ToString()); - - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal("Hello, Jack", body); - } - - [Fact] - public async Task NonJsonApiController_Skips_Middleware_And_Formatters_On_Patch() - { - // Arrange - const string route = "testValues?name=Jack"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Patch, route) {Content = new StringContent("XXX")}; - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("text/plain; charset=utf-8", response.Content.Headers.ContentType.ToString()); - - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal("Hello, Jack", body); - } - - [Fact] - public async Task NonJsonApiController_Skips_Middleware_And_Formatters_On_Delete() - { - // Arrange - const string route = "testValues"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Delete, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("text/plain; charset=utf-8", response.Content.Headers.ContentType.ToString()); - - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal("Deleted", body); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs new file mode 100644 index 0000000000..e1e41af7be --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs @@ -0,0 +1,153 @@ +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NonJsonApiControllers +{ + public sealed class NonJsonApiControllerTests : IClassFixture> + { + private readonly WebApplicationFactory _factory; + + public NonJsonApiControllerTests(WebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Get_skips_middleware_and_formatters() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "/NonJsonApi"); + + var client = _factory.CreateClient(); + + // Act + var httpResponse = await client.SendAsync(request); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ToString().Should().Be("application/json; charset=utf-8"); + + string responseText = await httpResponse.Content.ReadAsStringAsync(); + responseText.Should().Be("[\"Welcome!\"]"); + } + + [Fact] + public async Task Post_skips_middleware_and_formatters() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Post, "/NonJsonApi") + { + Content = new StringContent("Jack") + { + Headers = + { + ContentType = new MediaTypeHeaderValue("text/plain") + } + } + }; + + var client = _factory.CreateClient(); + + // Act + var httpResponse = await client.SendAsync(request); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); + + string responseText = await httpResponse.Content.ReadAsStringAsync(); + responseText.Should().Be("Hello, Jack"); + } + + [Fact] + public async Task Post_skips_error_handler() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Post, "/NonJsonApi"); + + var client = _factory.CreateClient(); + + // Act + var httpResponse = await client.SendAsync(request); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); + + string responseText = await httpResponse.Content.ReadAsStringAsync(); + responseText.Should().Be("Please send your name."); + } + + [Fact] + public async Task Put_skips_middleware_and_formatters() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Put, "/NonJsonApi") + { + Content = new StringContent("\"Jane\"") + { + Headers = + { + ContentType = new MediaTypeHeaderValue("application/json") + } + } + }; + + var client = _factory.CreateClient(); + + // Act + var httpResponse = await client.SendAsync(request); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); + + string responseText = await httpResponse.Content.ReadAsStringAsync(); + responseText.Should().Be("Hi, Jane"); + } + + [Fact] + public async Task Patch_skips_middleware_and_formatters() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Patch, "/NonJsonApi?name=Janice"); + + var client = _factory.CreateClient(); + + // Act + var httpResponse = await client.SendAsync(request); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); + + string responseText = await httpResponse.Content.ReadAsStringAsync(); + responseText.Should().Be("Good day, Janice"); + } + + [Fact] + public async Task Delete_skips_middleware_and_formatters() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Delete, "/NonJsonApi"); + + var client = _factory.CreateClient(); + + // Act + var httpResponse = await client.SendAsync(request); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); + + string responseText = await httpResponse.Content.ReadAsStringAsync(); + responseText.Should().Be("Bye."); + } + } +} From 22738b64d1f0913750133fb3c1f8f31993b2911c Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 28 Jan 2021 18:45:03 +0100 Subject: [PATCH 084/123] Refactored tests for ActionResult usage --- .../Controllers/TodoItemsTestController.cs | 110 -------------- .../Acceptance/ActionResultTests.cs | 117 --------------- .../ActionResultDbContext.cs | 14 ++ .../ActionResultTests.cs | 142 ++++++++++++++++++ .../ControllerActionResults/Toothbrush.cs | 8 + .../ToothbrushesController.cs | 78 ++++++++++ 6 files changed, 242 insertions(+), 227 deletions(-) delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/ActionResultTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/Toothbrush.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs deleted file mode 100644 index b4d0f9fe2c..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsTestController.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExample.Controllers -{ - public abstract class AbstractTodoItemsController - : BaseJsonApiController where T : class, IIdentifiable - { - protected AbstractTodoItemsController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IResourceService service) - : base(options, loggerFactory, service) - { } - } - - [DisableRoutingConvention] - [Route("/abstract")] - public class TodoItemsTestController : AbstractTodoItemsController - { - public TodoItemsTestController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IResourceService service) - : base(options, loggerFactory, service) - { } - - [HttpGet] - public override async Task GetAsync(CancellationToken cancellationToken) - { - return await base.GetAsync(cancellationToken); - } - - [HttpGet("{id}")] - public override async Task GetAsync(int id, CancellationToken cancellationToken) - { - return await base.GetAsync(id, cancellationToken); - } - - [HttpGet("{id}/{relationshipName}")] - public override async Task GetSecondaryAsync(int id, string relationshipName, CancellationToken cancellationToken) - { - return await base.GetSecondaryAsync(id, relationshipName, cancellationToken); - } - - [HttpGet("{id}/relationships/{relationshipName}")] - public override async Task GetRelationshipAsync(int id, string relationshipName, CancellationToken cancellationToken) - { - return await base.GetRelationshipAsync(id, relationshipName, cancellationToken); - } - - [HttpPost] - public override async Task PostAsync([FromBody] TodoItem resource, CancellationToken cancellationToken) - { - await Task.Yield(); - - return NotFound(new Error(HttpStatusCode.NotFound) - { - Title = "NotFound ActionResult with explicit error object." - }); - } - - [HttpPost("{id}/relationships/{relationshipName}")] - public override async Task PostRelationshipAsync( - int id, string relationshipName, [FromBody] ISet secondaryResourceIds, CancellationToken cancellationToken) - { - return await base.PostRelationshipAsync(id, relationshipName, secondaryResourceIds, cancellationToken); - } - - [HttpPatch("{id}")] - public override async Task PatchAsync(int id, [FromBody] TodoItem resource, CancellationToken cancellationToken) - { - await Task.Yield(); - - return Conflict("Something went wrong"); - } - - [HttpPatch("{id}/relationships/{relationshipName}")] - public override async Task PatchRelationshipAsync( - int id, string relationshipName, [FromBody] object secondaryResourceIds, CancellationToken cancellationToken) - { - return await base.PatchRelationshipAsync(id, relationshipName, secondaryResourceIds, cancellationToken); - } - - [HttpDelete("{id}")] - public override async Task DeleteAsync(int id, CancellationToken cancellationToken) - { - await Task.Yield(); - - return NotFound(); - } - - [HttpDelete("{id}/relationships/{relationshipName}")] - public override async Task DeleteRelationshipAsync(int id, string relationshipName, [FromBody] ISet secondaryResourceIds, CancellationToken cancellationToken) - { - return await base.DeleteRelationshipAsync(id, relationshipName, secondaryResourceIds, cancellationToken); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ActionResultTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ActionResultTests.cs deleted file mode 100644 index 5fd33a87f5..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ActionResultTests.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - [Collection("WebHostCollection")] - public sealed class ActionResultTests - { - private readonly TestFixture _fixture; - - public ActionResultTests(TestFixture fixture) - { - _fixture = fixture; - } - - [Fact] - public async Task ActionResult_With_Error_Object_Is_Converted_To_Error_Collection() - { - // Arrange - var route = "/abstract"; - var request = new HttpRequestMessage(HttpMethod.Post, route); - var content = new - { - data = new - { - type = "todoItems", - id = 1, - attributes = new Dictionary - { - {"ordinal", 1} - } - } - }; - - request.Content = new StringContent(JsonConvert.SerializeObject(content)); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("NotFound ActionResult with explicit error object.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Empty_ActionResult_Is_Converted_To_Error_Collection() - { - // Arrange - var route = "/abstract/123"; - var request = new HttpRequestMessage(HttpMethod.Delete, route); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.NotFound, errorDocument.Errors[0].StatusCode); - Assert.Equal("NotFound", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task ActionResult_With_String_Object_Is_Converted_To_Error_Collection() - { - // Arrange - var route = "/abstract/123"; - var request = new HttpRequestMessage(HttpMethod.Patch, route); - var content = new - { - data = new - { - type = "todoItems", - id = 123, - attributes = new Dictionary - { - {"ordinal", 1} - } - } - }; - - request.Content = new StringContent(JsonConvert.SerializeObject(content)); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _fixture.Client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.InternalServerError, errorDocument.Errors[0].StatusCode); - Assert.Equal("An unhandled error occurred while processing this request.", errorDocument.Errors[0].Title); - Assert.Equal("Data being returned must be errors or resources.", errorDocument.Errors[0].Detail); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs new file mode 100644 index 0000000000..4db7d81157 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ControllerActionResults +{ + public sealed class ActionResultDbContext : DbContext + { + public DbSet Toothbrushes { get; set; } + + public ActionResultDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs new file mode 100644 index 0000000000..2b0adc0580 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs @@ -0,0 +1,142 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ControllerActionResults +{ + public sealed class ActionResultTests + : IClassFixture, ActionResultDbContext>> + { + private readonly IntegrationTestContext, ActionResultDbContext> _testContext; + + public ActionResultTests(IntegrationTestContext, ActionResultDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_get_resource_by_ID() + { + // Arrange + var toothbrush = new Toothbrush(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Toothbrushes.Add(toothbrush); + await dbContext.SaveChangesAsync(); + }); + + var route = "/toothbrushes/" + toothbrush.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(toothbrush.StringId); + } + + [Fact] + public async Task Converts_empty_ActionResult_to_error_collection() + { + // Arrange + var route = "/toothbrushes/11111111"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // 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("NotFound"); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Converts_ActionResult_with_error_object_to_error_collection() + { + // Arrange + var route = "/toothbrushes/22222222"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // 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("No toothbrush with that ID exists."); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_convert_ActionResult_with_string_parameter_to_error_collection() + { + // Arrange + var route = "/toothbrushes/33333333"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.InternalServerError); + responseDocument.Errors[0].Title.Should().Be("An unhandled error occurred while processing this request."); + responseDocument.Errors[0].Detail.Should().Be("Data being returned must be errors or resources."); + } + + [Fact] + public async Task Converts_ObjectResult_with_error_object_to_error_collection() + { + // Arrange + var route = "/toothbrushes/44444444"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadGateway); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.BadGateway); + responseDocument.Errors[0].Title.Should().BeNull(); + responseDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Converts_ObjectResult_with_error_objects_to_error_collection() + { + // Arrange + var route = "/toothbrushes/55555555"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(3); + + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); + responseDocument.Errors[0].Title.Should().BeNull(); + responseDocument.Errors[0].Detail.Should().BeNull(); + + responseDocument.Errors[1].StatusCode.Should().Be(HttpStatusCode.Unauthorized); + responseDocument.Errors[1].Title.Should().BeNull(); + responseDocument.Errors[1].Detail.Should().BeNull(); + + responseDocument.Errors[2].StatusCode.Should().Be(HttpStatusCode.ExpectationFailed); + responseDocument.Errors[2].Title.Should().Be("This is not a very great request."); + responseDocument.Errors[2].Detail.Should().BeNull(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/Toothbrush.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/Toothbrush.cs new file mode 100644 index 0000000000..60a2e5a869 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/Toothbrush.cs @@ -0,0 +1,8 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ControllerActionResults +{ + public sealed class Toothbrush : Identifiable + { + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs new file mode 100644 index 0000000000..8155d1ba3b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs @@ -0,0 +1,78 @@ +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ControllerActionResults +{ + public sealed class ToothbrushesController : BaseToothbrushesController + { + public ToothbrushesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + + [HttpGet("{id}")] + public override Task GetAsync(int id, CancellationToken cancellationToken) + { + return base.GetAsync(id, cancellationToken); + } + } + + public abstract class BaseToothbrushesController : BaseJsonApiController + { + protected BaseToothbrushesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + + public override async Task GetAsync(int id, CancellationToken cancellationToken) + { + if (id == 11111111) + { + return NotFound(); + } + + if (id == 22222222) + { + return NotFound(new Error(HttpStatusCode.NotFound) + { + Title = "No toothbrush with that ID exists." + }); + } + + if (id == 33333333) + { + return Conflict("Something went wrong."); + } + + if (id == 44444444) + { + return Error(new Error(HttpStatusCode.BadGateway)); + } + + if (id == 55555555) + { + var errors = new[] + { + new Error(HttpStatusCode.PreconditionFailed), + new Error(HttpStatusCode.Unauthorized), + new Error(HttpStatusCode.ExpectationFailed) + { + Title = "This is not a very great request." + } + }; + return Error(errors); + } + + return await base.GetAsync(id, cancellationToken); + } + } +} From e12d27f121300b74e64c3db46b278f771f3ca61d Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 2 Feb 2021 12:15:24 +0100 Subject: [PATCH 085/123] Refactored tests for custom routes --- .../Controllers/TodoItemsCustomController.cs | 149 ---------------- .../Extensibility/CustomControllerTests.cs | 159 ------------------ .../ApiControllerAttributeTests.cs | 35 ++++ .../IntegrationTests/CustomRoutes/Civilian.cs | 11 ++ .../CustomRoutes/CiviliansController.cs | 28 +++ .../CustomRoutes/CustomRouteDbContext.cs | 15 ++ .../CustomRoutes/CustomRouteFakers.cs | 23 +++ .../CustomRoutes/CustomRouteTests.cs | 80 +++++++++ .../IntegrationTests/CustomRoutes/Town.cs | 21 +++ .../CustomRoutes/TownsController.cs | 37 ++++ 10 files changed, 250 insertions(+), 308 deletions(-) delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/Civilian.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CiviliansController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteFakers.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/Town.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/TownsController.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs deleted file mode 100644 index 721f126648..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsCustomController.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Mvc; - -namespace JsonApiDotNetCoreExample.Controllers -{ - [ApiController] - [DisableRoutingConvention, Route("custom/route/todoItems")] - public class TodoItemsCustomController : CustomJsonApiController - { - public TodoItemsCustomController( - IJsonApiOptions options, - IResourceService resourceService) - : base(options, resourceService) - { } - } - - public class CustomJsonApiController - : CustomJsonApiController where T : class, IIdentifiable - { - public CustomJsonApiController( - IJsonApiOptions options, - IResourceService resourceService) - : base(options, resourceService) - { - } - } - - public class CustomJsonApiController - : ControllerBase where T : class, IIdentifiable - { - private readonly IJsonApiOptions _options; - private readonly IResourceService _resourceService; - - private IActionResult Forbidden() - { - return new StatusCodeResult((int)HttpStatusCode.Forbidden); - } - - public CustomJsonApiController( - IJsonApiOptions options, - IResourceService resourceService) - { - _options = options; - _resourceService = resourceService; - } - - public CustomJsonApiController( - IResourceService resourceService) - { - _resourceService = resourceService; - } - - [HttpGet] - public async Task GetAsync(CancellationToken cancellationToken) - { - var resources = await _resourceService.GetAsync(cancellationToken); - return Ok(resources); - } - - [HttpGet("{id}")] - public async Task GetAsync(TId id, CancellationToken cancellationToken) - { - try - { - var resource = await _resourceService.GetAsync(id, cancellationToken); - return Ok(resource); - } - catch (ResourceNotFoundException) - { - return NotFound(); - } - } - - [HttpGet("{id}/relationships/{relationshipName}")] - public async Task GetRelationshipsAsync(TId id, string relationshipName, CancellationToken cancellationToken) - { - try - { - var relationship = await _resourceService.GetRelationshipAsync(id, relationshipName, cancellationToken); - return Ok(relationship); - } - catch (ResourceNotFoundException) - { - return NotFound(); - } - } - - [HttpGet("{id}/{relationshipName}")] - public async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) - { - var relationship = await _resourceService.GetSecondaryAsync(id, relationshipName, cancellationToken); - return Ok(relationship); - } - - [HttpPost] - public async Task PostAsync([FromBody] T resource, CancellationToken cancellationToken) - { - if (resource == null) - return UnprocessableEntity(); - - if (_options.AllowClientGeneratedIds && !string.IsNullOrEmpty(resource.StringId)) - return Forbidden(); - - resource = await _resourceService.CreateAsync(resource, cancellationToken); - - return Created($"{HttpContext.Request.Path}/{resource.Id}", resource); - } - - [HttpPatch("{id}")] - public async Task PatchAsync(TId id, [FromBody] T resource, CancellationToken cancellationToken) - { - if (resource == null) - return UnprocessableEntity(); - - try - { - var updated = await _resourceService.UpdateAsync(id, resource, cancellationToken); - return Ok(updated); - } - catch (ResourceNotFoundException) - { - return NotFound(); - } - } - - [HttpPatch("{id}/relationships/{relationshipName}")] - public async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object secondaryResourceIds, CancellationToken cancellationToken) - { - await _resourceService.SetRelationshipAsync(id, relationshipName, secondaryResourceIds, cancellationToken); - - return Ok(); - } - - [HttpDelete("{id}")] - public async Task DeleteAsync(TId id, CancellationToken cancellationToken) - { - await _resourceService.DeleteAsync(id, cancellationToken); - return NoContent(); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs deleted file mode 100644 index 61c467cbdc..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Extensibility -{ - [Collection("WebHostCollection")] - public sealed class CustomControllerTests - { - private readonly TestFixture _fixture; - private readonly Faker _todoItemFaker; - private readonly Faker _personFaker; - - public CustomControllerTests(TestFixture fixture) - { - _fixture = fixture; - _todoItemFaker = new Faker() - .RuleFor(t => t.Description, f => f.Lorem.Sentence()) - .RuleFor(t => t.Ordinal, f => f.Random.Number()); - _personFaker = new Faker() - .RuleFor(p => p.FirstName, f => f.Name.FirstName()) - .RuleFor(p => p.LastName, f => f.Name.LastName()); - } - - [Fact] - public async Task CustomRouteControllers_Uses_Dasherized_Collection_Route() - { - // Arrange - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - var route = "/custom/route/todoItems"; - - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task CustomRouteControllers_Uses_Dasherized_Item_Route() - { - // Arrange - var context = _fixture.GetRequiredService(); - var todoItem = _todoItemFaker.Generate(); - var person = _personFaker.Generate(); - todoItem.Owner = person; - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder() - .UseStartup(); - var httpMethod = new HttpMethod("GET"); - var route = $"/custom/route/todoItems/{todoItem.Id}"; - - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } - - [Fact] - public async Task CustomRouteControllers_Creates_Proper_Relationship_Links() - { - // Arrange - var context = _fixture.GetRequiredService(); - var todoItem = _todoItemFaker.Generate(); - var person = _personFaker.Generate(); - todoItem.Owner = person; - context.TodoItems.Add(todoItem); - await context.SaveChangesAsync(); - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var httpMethod = new HttpMethod("GET"); - var route = $"/custom/route/todoItems/{todoItem.Id}"; - - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var body = await response.Content.ReadAsStringAsync(); - var deserializedBody = JsonConvert.DeserializeObject(body); - - var result = deserializedBody["data"]["relationships"]["owner"]["links"]["related"].ToString(); - Assert.EndsWith($"{route}/owner", result); - } - - [Fact] - public async Task ApiController_attribute_transforms_NotFound_action_result_without_arguments_into_ProblemDetails() - { - // Arrange - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var route = "/custom/route/todoItems/99999999"; - - var requestBody = new - { - data = new - { - type = "todoItems", - id = "99999999", - attributes = new Dictionary - { - ["ordinal"] = 1 - } - } - }; - - var content = JsonConvert.SerializeObject(requestBody); - - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(HttpMethod.Patch, route) {Content = new StringContent(content)}; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - - var responseBody = await response.Content.ReadAsStringAsync(); - var errorDocument = JsonConvert.DeserializeObject(responseBody); - - Assert.Single(errorDocument.Errors); - Assert.Equal("https://tools.ietf.org/html/rfc7231#section-6.5.4", errorDocument.Errors[0].Links.About); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs new file mode 100644 index 0000000000..3a38991118 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs @@ -0,0 +1,35 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes +{ + public sealed class ApiControllerAttributeTests + : IClassFixture, CustomRouteDbContext>> + { + private readonly IntegrationTestContext, CustomRouteDbContext> _testContext; + + public ApiControllerAttributeTests(IntegrationTestContext, CustomRouteDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task ApiController_attribute_transforms_NotFound_action_result_without_arguments_into_ProblemDetails() + { + // Arrange + var route = "/world-civilians/missing"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].Links.About.Should().Be("https://tools.ietf.org/html/rfc7231#section-6.5.4"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/Civilian.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/Civilian.cs new file mode 100644 index 0000000000..c5f75a2c38 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/Civilian.cs @@ -0,0 +1,11 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes +{ + public sealed class Civilian : Identifiable + { + [Attr] + public string Name { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CiviliansController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CiviliansController.cs new file mode 100644 index 0000000000..dd2df9b6d6 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CiviliansController.cs @@ -0,0 +1,28 @@ +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes +{ + [ApiController] + [DisableRoutingConvention, Route("world-civilians")] + public sealed class CiviliansController : JsonApiController + { + public CiviliansController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + + [HttpGet("missing")] + public async Task GetMissingAsync() + { + await Task.Yield(); + return NotFound(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs new file mode 100644 index 0000000000..4b76077d27 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes +{ + public sealed class CustomRouteDbContext : DbContext + { + public DbSet Towns { get; set; } + public DbSet Civilians { get; set; } + + public CustomRouteDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteFakers.cs new file mode 100644 index 0000000000..e57febd9c4 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteFakers.cs @@ -0,0 +1,23 @@ +using System; +using Bogus; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes +{ + internal sealed class CustomRouteFakers : FakerContainer + { + private readonly Lazy> _lazyTownFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(town => town.Name, f => f.Address.City()) + .RuleFor(town => town.Latitude, f => f.Address.Latitude()) + .RuleFor(town => town.Longitude, f => f.Address.Longitude())); + + private readonly Lazy> _lazyCivilianFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(civilian => civilian.Name, f => f.Person.FullName)); + + public Faker Town => _lazyTownFaker.Value; + public Faker Civilian => _lazyCivilianFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs new file mode 100644 index 0000000000..e708cb8b13 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs @@ -0,0 +1,80 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes +{ + public sealed class CustomRouteTests + : IClassFixture, CustomRouteDbContext>> + { + private readonly IntegrationTestContext, CustomRouteDbContext> _testContext; + private readonly CustomRouteFakers _fakers = new CustomRouteFakers(); + + public CustomRouteTests(IntegrationTestContext, CustomRouteDbContext> testContext) + { + _testContext = testContext; + } + + [Fact] + public async Task Can_get_resource_at_custom_route() + { + // Arrange + var town = _fakers.Town.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Towns.Add(town); + await dbContext.SaveChangesAsync(); + }); + + var route = "/world-api/civilization/popular/towns/" + town.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Type.Should().Be("towns"); + responseDocument.SingleData.Id.Should().Be(town.StringId); + responseDocument.SingleData.Attributes["name"].Should().Be(town.Name); + responseDocument.SingleData.Attributes["latitude"].Should().Be(town.Latitude); + responseDocument.SingleData.Attributes["longitude"].Should().Be(town.Longitude); + responseDocument.SingleData.Relationships["civilians"].Links.Self.Should().Be($"http://localhost/world-api/civilization/popular/towns/{town.Id}/relationships/civilians"); + responseDocument.SingleData.Relationships["civilians"].Links.Related.Should().Be($"http://localhost/world-api/civilization/popular/towns/{town.Id}/civilians"); + responseDocument.SingleData.Links.Self.Should().Be($"http://localhost/world-api/civilization/popular/towns/{town.Id}"); + responseDocument.Links.Self.Should().Be($"http://localhost/world-api/civilization/popular/towns/{town.Id}"); + } + + [Fact] + public async Task Can_get_resources_at_custom_action_method() + { + // Arrange + var town = _fakers.Town.Generate(7); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Towns.AddRange(town); + await dbContext.SaveChangesAsync(); + }); + + var route = "/world-api/civilization/popular/towns/largest-5"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(5); + responseDocument.ManyData.Should().OnlyContain(resourceObject => resourceObject.Type == "towns"); + responseDocument.ManyData.Should().OnlyContain(resourceObject => resourceObject.Attributes.Any()); + responseDocument.ManyData.Should().OnlyContain(resourceObject => resourceObject.Relationships.Any()); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/Town.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/Town.cs new file mode 100644 index 0000000000..0f2dc93926 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/Town.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes +{ + public sealed class Town : Identifiable + { + [Attr] + public string Name { get; set; } + + [Attr] + public double Latitude { get; set; } + + [Attr] + public double Longitude { get; set; } + + [HasMany] + public ISet Civilians { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/TownsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/TownsController.cs new file mode 100644 index 0000000000..af390bd683 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/TownsController.cs @@ -0,0 +1,37 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes +{ + [DisableRoutingConvention, Route("world-api/civilization/popular/towns")] + public sealed class TownsController : JsonApiController + { + private readonly CustomRouteDbContext _dbContext; + + public TownsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService, CustomRouteDbContext dbContext) + : base(options, loggerFactory, resourceService) + { + _dbContext = dbContext; + } + + [HttpGet("largest-{count}")] + public async Task GetLargestTownsAsync(int count, CancellationToken cancellationToken) + { + var query = _dbContext.Towns + .OrderByDescending(town => town.Civilians.Count) + .Take(count); + + var results = await query.ToListAsync(cancellationToken); + return Ok(results); + } + } +} From ddcd4aca6a7398bfe8eba4257c5916ebcc60908a Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 2 Feb 2021 16:42:58 +0100 Subject: [PATCH 086/123] Refactored tests for links rendering --- .../DocumentTests/LinksWithNamespaceTests.cs | 43 --- .../LinksWithoutNamespaceTests.cs | 99 ----- .../Spec/DocumentTests/Relationships.cs | 159 -------- .../Acceptance/TestFixture.cs | 60 --- .../Links/AbsoluteLinksWithNamespaceTests.cs | 355 ++++++++++++++++++ .../AbsoluteLinksWithoutNamespaceTests.cs | 355 ++++++++++++++++++ .../IntegrationTests/Links/LinksDbContext.cs | 15 + .../IntegrationTests/Links/LinksFakers.cs | 21 ++ .../IntegrationTests/Links/Photo.cs | 18 + .../IntegrationTests/Links/PhotoAlbum.cs | 19 + .../Links/PhotoAlbumsController.cs | 17 + .../Links/PhotosController.cs | 17 + .../Links/RelativeLinksWithNamespaceTests.cs | 304 ++++++++++++--- .../RelativeLinksWithoutNamespaceTests.cs | 355 ++++++++++++++++++ .../ModelStateValidationTests.cs | 1 + .../AbsoluteLinksInApiNamespaceStartup.cs | 24 ++ .../AbsoluteLinksNoNamespaceStartup.cs | 24 ++ .../ModelStateValidationStartup.cs | 6 +- .../RelativeLinksInApiNamespaceStartup.cs | 24 ++ .../RelativeLinksNoNamespaceStartup.cs | 24 ++ .../WebHostCollection.cs | 10 - 21 files changed, 1530 insertions(+), 420 deletions(-) delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithNamespaceTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithoutNamespaceTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksFakers.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/Photo.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoAlbum.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoAlbumsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotosController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Startups/AbsoluteLinksInApiNamespaceStartup.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Startups/AbsoluteLinksNoNamespaceStartup.cs rename test/JsonApiDotNetCoreExampleTests/{IntegrationTests/ModelStateValidation => Startups}/ModelStateValidationStartup.cs (78%) create mode 100644 test/JsonApiDotNetCoreExampleTests/Startups/RelativeLinksInApiNamespaceStartup.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/Startups/RelativeLinksNoNamespaceStartup.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/WebHostCollection.cs diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithNamespaceTests.cs deleted file mode 100644 index 6b51a357fd..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithNamespaceTests.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Models; -using Newtonsoft.Json; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec.DocumentTests -{ - public sealed class LinksWithNamespaceTests : FunctionalTestCollection - { - public LinksWithNamespaceTests(StandardApplicationFactory factory) : base(factory) - { - } - - [Fact] - public async Task GET_RelativeLinks_False_With_Namespace_Returns_AbsoluteLinks() - { - // Arrange - var person = new Person(); - - _dbContext.People.Add(person); - await _dbContext.SaveChangesAsync(); - - var route = "/api/v1/people/" + person.StringId; - var request = new HttpRequestMessage(HttpMethod.Get, route); - - var options = (JsonApiOptions) _factory.GetRequiredService(); - options.UseRelativeLinks = false; - - // Act - var response = await _factory.Client.SendAsync(request); - var responseString = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(responseString); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal("http://localhost/api/v1/people/" + person.StringId, document.Links.Self); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithoutNamespaceTests.cs deleted file mode 100644 index 9670d5003a..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/LinksWithoutNamespaceTests.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System.Net; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Mvc.ApplicationParts; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec.DocumentTests -{ - public sealed class LinksWithoutNamespaceTests : IClassFixture> - { - private readonly IntegrationTestContext _testContext; - - public LinksWithoutNamespaceTests(IntegrationTestContext testContext) - { - _testContext = testContext; - - testContext.ConfigureServicesAfterStartup(services => - { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); - }); - } - - [Fact] - public async Task GET_RelativeLinks_True_Without_Namespace_Returns_RelativeLinks() - { - // Arrange - var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); - options.UseRelativeLinks = true; - - var person = new Person(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.People.Add(person); - - await dbContext.SaveChangesAsync(); - }); - - var route = "/people/" + person.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be("/people/" + person.StringId); - } - - [Fact] - public async Task GET_RelativeLinks_False_Without_Namespace_Returns_AbsoluteLinks() - { - // Arrange - var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); - options.UseRelativeLinks = false; - - var person = new Person(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.People.Add(person); - - await dbContext.SaveChangesAsync(); - }); - - var route = "/people/" + person.StringId; - - // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be("http://localhost/people/" + person.StringId); - } - } - - public sealed class NoNamespaceStartup : TestStartup - { - public NoNamespaceStartup(IConfiguration configuration) : base(configuration) - { - } - - protected override void ConfigureJsonApiOptions(JsonApiOptions options) - { - base.ConfigureJsonApiOptions(options); - - options.Namespace = null; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs deleted file mode 100644 index dacac855a8..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/DocumentTests/Relationships.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Newtonsoft.Json; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec.DocumentTests -{ - [Collection("WebHostCollection")] - public sealed class Relationships - { - private readonly AppDbContext _context; - private readonly Faker _todoItemFaker; - private readonly Faker _personFaker; - - public Relationships(TestFixture fixture) - { - _context = fixture.GetRequiredService(); - _todoItemFaker = new Faker() - .RuleFor(t => t.Description, f => f.Lorem.Sentence()) - .RuleFor(t => t.Ordinal, f => f.Random.Number()) - .RuleFor(t => t.CreatedDate, f => f.Date.Past()); - _personFaker = new Faker() - .RuleFor(p => p.FirstName, f => f.Name.FirstName()) - .RuleFor(p => p.LastName, f => f.Name.LastName()); - } - - [Fact] - public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships() - { - // Arrange - await _context.ClearTableAsync(); - await _context.SaveChangesAsync(); - - var todoItem = _todoItemFaker.Generate(); - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/todoItems"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - var responseString = await response.Content.ReadAsStringAsync(); - var data = JsonConvert.DeserializeObject(responseString).ManyData[0]; - var expectedOwnerSelfLink = $"http://localhost/api/v1/todoItems/{todoItem.Id}/relationships/owner"; - var expectedOwnerRelatedLink = $"http://localhost/api/v1/todoItems/{todoItem.Id}/owner"; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(expectedOwnerSelfLink, data.Relationships["owner"].Links.Self); - Assert.Equal(expectedOwnerRelatedLink, data.Relationships["owner"].Links.Related); - } - - [Fact] - public async Task Correct_RelationshipObjects_For_ManyToOne_Relationships_ById() - { - // Arrange - var todoItem = _todoItemFaker.Generate(); - _context.TodoItems.Add(todoItem); - await _context.SaveChangesAsync(); - - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/todoItems/{todoItem.Id}"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - var responseString = await response.Content.ReadAsStringAsync(); - var data = JsonConvert.DeserializeObject(responseString).SingleData; - var expectedOwnerSelfLink = $"http://localhost/api/v1/todoItems/{todoItem.Id}/relationships/owner"; - var expectedOwnerRelatedLink = $"http://localhost/api/v1/todoItems/{todoItem.Id}/owner"; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(expectedOwnerSelfLink, data.Relationships["owner"].Links.Self); - Assert.Equal(expectedOwnerRelatedLink, data.Relationships["owner"].Links.Related); - } - - [Fact] - public async Task Correct_RelationshipObjects_For_OneToMany_Relationships() - { - // Arrange - await _context.ClearTableAsync(); - await _context.SaveChangesAsync(); - - var person = _personFaker.Generate(); - _context.People.Add(person); - await _context.SaveChangesAsync(); - - var httpMethod = new HttpMethod("GET"); - var route = "/api/v1/people"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - var responseString = await response.Content.ReadAsStringAsync(); - var data = JsonConvert.DeserializeObject(responseString).ManyData[0]; - var expectedOwnerSelfLink = $"http://localhost/api/v1/people/{person.Id}/relationships/todoItems"; - var expectedOwnerRelatedLink = $"http://localhost/api/v1/people/{person.Id}/todoItems"; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(expectedOwnerSelfLink, data.Relationships["todoItems"].Links.Self); - Assert.Equal(expectedOwnerRelatedLink, data.Relationships["todoItems"].Links.Related); - } - - [Fact] - public async Task Correct_RelationshipObjects_For_OneToMany_Relationships_ById() - { - // Arrange - var person = _personFaker.Generate(); - _context.People.Add(person); - await _context.SaveChangesAsync(); - - var httpMethod = new HttpMethod("GET"); - var route = $"/api/v1/people/{person.Id}"; - - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - var server = new TestServer(builder); - var client = server.CreateClient(); - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await client.SendAsync(request); - var responseString = await response.Content.ReadAsStringAsync(); - var data = JsonConvert.DeserializeObject(responseString).SingleData; - var expectedOwnerSelfLink = $"http://localhost/api/v1/people/{person.Id}/relationships/todoItems"; - var expectedOwnerRelatedLink = $"http://localhost/api/v1/people/{person.Id}/todoItems"; - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - Assert.Equal(expectedOwnerSelfLink, data.Relationships["todoItems"].Links.Self); - Assert.Equal(expectedOwnerRelatedLink, data.Relationships["todoItems"].Links.Related); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs deleted file mode 100644 index 9136abc00c..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/TestFixture.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using JsonApiDotNetCore.Repositories; -using JsonApiDotNetCoreExample.Data; -using Microsoft.AspNetCore; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - public class TestFixture : IDisposable where TStartup : class - { - private readonly TestServer _server; - public readonly IServiceProvider ServiceProvider; - public TestFixture() - { - var builder = WebHost.CreateDefaultBuilder().UseStartup(); - _server = new TestServer(builder); - ServiceProvider = _server.Host.Services; - - Client = _server.CreateClient(); - Context = GetRequiredService().GetContext() as AppDbContext; - } - - public HttpClient Client { get; set; } - public AppDbContext Context { get; } - - public T GetRequiredService() => (T)ServiceProvider.GetRequiredService(typeof(T)); - - public void AssertEqualStatusCode(HttpStatusCode expected, HttpResponseMessage response) - { - var responseBody = response.Content.ReadAsStringAsync().Result; - Assert.True(expected == response.StatusCode, $"Got {response.StatusCode} status code instead of {expected}. Response body: {responseBody}"); - } - - private bool disposedValue; - - private void Dispose(bool disposing) - { - if (!disposedValue) - { - if (disposing) - { - Client.Dispose(); - _server.Dispose(); - } - - disposedValue = true; - } - } - - public void Dispose() - { - Dispose(true); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs new file mode 100644 index 0000000000..337668586f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs @@ -0,0 +1,355 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links +{ + public sealed class AbsoluteLinksWithNamespaceTests + : IClassFixture, LinksDbContext>> + { + private readonly IntegrationTestContext, LinksDbContext> _testContext; + private readonly LinksFakers _fakers = new LinksFakers(); + + public AbsoluteLinksWithNamespaceTests(IntegrationTestContext, LinksDbContext> testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.IncludeTotalResourceCount = true; + } + + [Fact] + public async Task Get_primary_resource_by_ID_returns_absolute_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/photoAlbums/" + album.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"http://localhost/api/photoAlbums/{album.StringId}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"http://localhost/api/photoAlbums/{album.StringId}"); + responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be($"http://localhost/api/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be($"http://localhost/api/photoAlbums/{album.StringId}/photos"); + } + + [Fact] + public async Task Get_primary_resources_with_include_returns_absolute_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/photoAlbums?include=photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be("http://localhost/api/photoAlbums?include=photos"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be("http://localhost/api/photoAlbums?include=photos"); + responseDocument.Links.Last.Should().Be("http://localhost/api/photoAlbums?include=photos"); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Self.Should().Be($"http://localhost/api/photoAlbums/{album.StringId}"); + responseDocument.ManyData[0].Relationships["photos"].Links.Self.Should().Be($"http://localhost/api/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.ManyData[0].Relationships["photos"].Links.Related.Should().Be($"http://localhost/api/photoAlbums/{album.StringId}/photos"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"http://localhost/api/photos/{album.Photos.ElementAt(0).StringId}"); + responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"http://localhost/api/photos/{album.Photos.ElementAt(0).StringId}/relationships/album"); + responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"http://localhost/api/photos/{album.Photos.ElementAt(0).StringId}/album"); + } + + [Fact] + public async Task Get_secondary_resource_returns_absolute_links() + { + // Arrange + var photo = _fakers.Photo.Generate(); + photo.Album = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/photos/{photo.StringId}/album"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"http://localhost/api/photos/{photo.StringId}/album"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"http://localhost/api/photoAlbums/{photo.Album.StringId}"); + responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be($"http://localhost/api/photoAlbums/{photo.Album.StringId}/relationships/photos"); + responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be($"http://localhost/api/photoAlbums/{photo.Album.StringId}/photos"); + } + + [Fact] + public async Task Get_secondary_resources_returns_absolute_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/photoAlbums/{album.StringId}/photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"http://localhost/api/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be($"http://localhost/api/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Self.Should().Be($"http://localhost/api/photos/{album.Photos.ElementAt(0).StringId}"); + responseDocument.ManyData[0].Relationships["album"].Links.Self.Should().Be($"http://localhost/api/photos/{album.Photos.ElementAt(0).StringId}/relationships/album"); + responseDocument.ManyData[0].Relationships["album"].Links.Related.Should().Be($"http://localhost/api/photos/{album.Photos.ElementAt(0).StringId}/album"); + } + + [Fact] + public async Task Get_HasOne_relationship_returns_absolute_links() + { + // Arrange + var photo = _fakers.Photo.Generate(); + photo.Album = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/photos/{photo.StringId}/relationships/album"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"http://localhost/api/photos/{photo.StringId}/relationships/album"); + responseDocument.Links.Related.Should().Be($"http://localhost/api/photos/{photo.StringId}/album"); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Should().BeNull(); + responseDocument.SingleData.Relationships.Should().BeNull(); + } + + [Fact] + public async Task Get_HasMany_relationship_returns_absolute_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/photoAlbums/{album.StringId}/relationships/photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"http://localhost/api/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.Links.Related.Should().Be($"http://localhost/api/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.First.Should().Be($"http://localhost/api/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Should().BeNull(); + responseDocument.ManyData[0].Relationships.Should().BeNull(); + } + + [Fact] + public async Task Create_resource_with_side_effects_and_include_returns_absolute_links() + { + // Arrange + var existingPhoto = _fakers.Photo.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(existingPhoto); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "photoAlbums", + relationships = new + { + photos = new + { + data = new[] + { + new + { + type = "photos", + id = existingPhoto.StringId + } + } + } + } + } + }; + + var route = "/api/photoAlbums?include=photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.Links.Self.Should().Be("http://localhost/api/photoAlbums?include=photos"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + var newAlbumId = responseDocument.SingleData.Id; + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"http://localhost/api/photoAlbums/{newAlbumId}"); + responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be($"http://localhost/api/photoAlbums/{newAlbumId}/relationships/photos"); + responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be($"http://localhost/api/photoAlbums/{newAlbumId}/photos"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"http://localhost/api/photos/{existingPhoto.StringId}"); + responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"http://localhost/api/photos/{existingPhoto.StringId}/relationships/album"); + responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"http://localhost/api/photos/{existingPhoto.StringId}/album"); + } + + [Fact] + public async Task Update_resource_with_side_effects_and_include_returns_absolute_links() + { + // Arrange + var existingPhoto = _fakers.Photo.Generate(); + var existingAlbum = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingPhoto, existingAlbum); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "photos", + id = existingPhoto.StringId, + relationships = new + { + album = new + { + data = new + { + type = "photoAlbums", + id = existingAlbum.StringId + } + } + } + } + }; + + var route = $"/api/photos/{existingPhoto.StringId}?include=album"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"http://localhost/api/photos/{existingPhoto.StringId}?include=album"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"http://localhost/api/photos/{existingPhoto.StringId}"); + responseDocument.SingleData.Relationships["album"].Links.Self.Should().Be($"http://localhost/api/photos/{existingPhoto.StringId}/relationships/album"); + responseDocument.SingleData.Relationships["album"].Links.Related.Should().Be($"http://localhost/api/photos/{existingPhoto.StringId}/album"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"http://localhost/api/photoAlbums/{existingAlbum.StringId}"); + responseDocument.Included[0].Relationships["photos"].Links.Self.Should().Be($"http://localhost/api/photoAlbums/{existingAlbum.StringId}/relationships/photos"); + responseDocument.Included[0].Relationships["photos"].Links.Related.Should().Be($"http://localhost/api/photoAlbums/{existingAlbum.StringId}/photos"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs new file mode 100644 index 0000000000..ee4091ebc2 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs @@ -0,0 +1,355 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links +{ + public sealed class AbsoluteLinksWithoutNamespaceTests + : IClassFixture, LinksDbContext>> + { + private readonly IntegrationTestContext, LinksDbContext> _testContext; + private readonly LinksFakers _fakers = new LinksFakers(); + + public AbsoluteLinksWithoutNamespaceTests(IntegrationTestContext, LinksDbContext> testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.IncludeTotalResourceCount = true; + } + + [Fact] + public async Task Get_primary_resource_by_ID_returns_absolute_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = "/photoAlbums/" + album.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"http://localhost/photoAlbums/{album.StringId}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"http://localhost/photoAlbums/{album.StringId}"); + responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be($"http://localhost/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be($"http://localhost/photoAlbums/{album.StringId}/photos"); + } + + [Fact] + public async Task Get_primary_resources_with_include_returns_absolute_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = "/photoAlbums?include=photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be("http://localhost/photoAlbums?include=photos"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be("http://localhost/photoAlbums?include=photos"); + responseDocument.Links.Last.Should().Be("http://localhost/photoAlbums?include=photos"); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Self.Should().Be($"http://localhost/photoAlbums/{album.StringId}"); + responseDocument.ManyData[0].Relationships["photos"].Links.Self.Should().Be($"http://localhost/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.ManyData[0].Relationships["photos"].Links.Related.Should().Be($"http://localhost/photoAlbums/{album.StringId}/photos"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"http://localhost/photos/{album.Photos.ElementAt(0).StringId}"); + responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"http://localhost/photos/{album.Photos.ElementAt(0).StringId}/relationships/album"); + responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"http://localhost/photos/{album.Photos.ElementAt(0).StringId}/album"); + } + + [Fact] + public async Task Get_secondary_resource_returns_absolute_links() + { + // Arrange + var photo = _fakers.Photo.Generate(); + photo.Album = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/photos/{photo.StringId}/album"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"http://localhost/photos/{photo.StringId}/album"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"http://localhost/photoAlbums/{photo.Album.StringId}"); + responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be($"http://localhost/photoAlbums/{photo.Album.StringId}/relationships/photos"); + responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be($"http://localhost/photoAlbums/{photo.Album.StringId}/photos"); + } + + [Fact] + public async Task Get_secondary_resources_returns_absolute_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/photoAlbums/{album.StringId}/photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"http://localhost/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be($"http://localhost/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Self.Should().Be($"http://localhost/photos/{album.Photos.ElementAt(0).StringId}"); + responseDocument.ManyData[0].Relationships["album"].Links.Self.Should().Be($"http://localhost/photos/{album.Photos.ElementAt(0).StringId}/relationships/album"); + responseDocument.ManyData[0].Relationships["album"].Links.Related.Should().Be($"http://localhost/photos/{album.Photos.ElementAt(0).StringId}/album"); + } + + [Fact] + public async Task Get_HasOne_relationship_returns_absolute_links() + { + // Arrange + var photo = _fakers.Photo.Generate(); + photo.Album = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/photos/{photo.StringId}/relationships/album"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"http://localhost/photos/{photo.StringId}/relationships/album"); + responseDocument.Links.Related.Should().Be($"http://localhost/photos/{photo.StringId}/album"); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Should().BeNull(); + responseDocument.SingleData.Relationships.Should().BeNull(); + } + + [Fact] + public async Task Get_HasMany_relationship_returns_absolute_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/photoAlbums/{album.StringId}/relationships/photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"http://localhost/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.Links.Related.Should().Be($"http://localhost/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.First.Should().Be($"http://localhost/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Should().BeNull(); + responseDocument.ManyData[0].Relationships.Should().BeNull(); + } + + [Fact] + public async Task Create_resource_with_side_effects_and_include_returns_absolute_links() + { + // Arrange + var existingPhoto = _fakers.Photo.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(existingPhoto); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "photoAlbums", + relationships = new + { + photos = new + { + data = new[] + { + new + { + type = "photos", + id = existingPhoto.StringId + } + } + } + } + } + }; + + var route = "/photoAlbums?include=photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.Links.Self.Should().Be("http://localhost/photoAlbums?include=photos"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + var newAlbumId = responseDocument.SingleData.Id; + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"http://localhost/photoAlbums/{newAlbumId}"); + responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be($"http://localhost/photoAlbums/{newAlbumId}/relationships/photos"); + responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be($"http://localhost/photoAlbums/{newAlbumId}/photos"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"http://localhost/photos/{existingPhoto.StringId}"); + responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"http://localhost/photos/{existingPhoto.StringId}/relationships/album"); + responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"http://localhost/photos/{existingPhoto.StringId}/album"); + } + + [Fact] + public async Task Update_resource_with_side_effects_and_include_returns_absolute_links() + { + // Arrange + var existingPhoto = _fakers.Photo.Generate(); + var existingAlbum = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingPhoto, existingAlbum); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "photos", + id = existingPhoto.StringId, + relationships = new + { + album = new + { + data = new + { + type = "photoAlbums", + id = existingAlbum.StringId + } + } + } + } + }; + + var route = $"/photos/{existingPhoto.StringId}?include=album"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"http://localhost/photos/{existingPhoto.StringId}?include=album"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"http://localhost/photos/{existingPhoto.StringId}"); + responseDocument.SingleData.Relationships["album"].Links.Self.Should().Be($"http://localhost/photos/{existingPhoto.StringId}/relationships/album"); + responseDocument.SingleData.Relationships["album"].Links.Related.Should().Be($"http://localhost/photos/{existingPhoto.StringId}/album"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"http://localhost/photoAlbums/{existingAlbum.StringId}"); + responseDocument.Included[0].Relationships["photos"].Links.Self.Should().Be($"http://localhost/photoAlbums/{existingAlbum.StringId}/relationships/photos"); + responseDocument.Included[0].Relationships["photos"].Links.Related.Should().Be($"http://localhost/photoAlbums/{existingAlbum.StringId}/photos"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksDbContext.cs new file mode 100644 index 0000000000..bb34911642 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksDbContext.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links +{ + public sealed class LinksDbContext : DbContext + { + public DbSet PhotoAlbums { get; set; } + public DbSet Photos { get; set; } + + public LinksDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksFakers.cs new file mode 100644 index 0000000000..852c825b3c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksFakers.cs @@ -0,0 +1,21 @@ +using System; +using Bogus; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links +{ + internal sealed class LinksFakers : FakerContainer + { + private readonly Lazy> _lazyPhotoAlbumFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(photoAlbum => photoAlbum.Name, f => f.Lorem.Sentence())); + + private readonly Lazy> _lazyPhotoFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(photo => photo.Url, f => f.Image.PlaceImgUrl())); + + public Faker PhotoAlbum => _lazyPhotoAlbumFaker.Value; + public Faker Photo => _lazyPhotoFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/Photo.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/Photo.cs new file mode 100644 index 0000000000..da964b4392 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/Photo.cs @@ -0,0 +1,18 @@ +using System; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links +{ + public sealed class Photo : Identifiable + { + [Attr] + public string Url { get; set; } + + [Attr] + public Guid ConcurrencyToken => Guid.NewGuid(); + + [HasOne] + public PhotoAlbum Album { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoAlbum.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoAlbum.cs new file mode 100644 index 0000000000..17087913d1 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoAlbum.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links +{ + public sealed class PhotoAlbum : Identifiable + { + [Attr] + public string Name { get; set; } + + [Attr] + public Guid ConcurrencyToken => Guid.NewGuid(); + + [HasMany] + public ISet Photos { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoAlbumsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoAlbumsController.cs new file mode 100644 index 0000000000..8d13f66b99 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoAlbumsController.cs @@ -0,0 +1,17 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links +{ + public sealed class PhotoAlbumsController : JsonApiController + { + public PhotoAlbumsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotosController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotosController.cs new file mode 100644 index 0000000000..e0dcb9a316 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotosController.cs @@ -0,0 +1,17 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links +{ + public sealed class PhotosController : JsonApiController + { + public PhotosController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs index 7d6b023bef..9dcc0009d4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs @@ -1,47 +1,42 @@ -using System.Collections.Generic; using System.Linq; using System.Net; using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links { public sealed class RelativeLinksWithNamespaceTests - : IClassFixture> + : IClassFixture, LinksDbContext>> { - private readonly IntegrationTestContext _testContext; + private readonly IntegrationTestContext, LinksDbContext> _testContext; + private readonly LinksFakers _fakers = new LinksFakers(); - public RelativeLinksWithNamespaceTests(IntegrationTestContext testContext) + public RelativeLinksWithNamespaceTests(IntegrationTestContext, LinksDbContext> testContext) { _testContext = testContext; var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); - options.Namespace = "api/v1"; - options.UseRelativeLinks = true; - options.DefaultPageSize = new PageSize(10); options.IncludeTotalResourceCount = true; } [Fact] - public async Task Get_primary_resource_by_ID_returns_links() + public async Task Get_primary_resource_by_ID_returns_relative_links() { // Arrange - var person = new Person(); + var album = _fakers.PhotoAlbum.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.People.Add(person); + dbContext.PhotoAlbums.Add(album); await dbContext.SaveChangesAsync(); }); - var route = "/api/v1/people/" + person.StringId; + var route = "/api/photoAlbums/" + album.StringId; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -49,7 +44,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be($"/api/v1/people/{person.StringId}"); + responseDocument.Links.Self.Should().Be($"/api/photoAlbums/{album.StringId}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); @@ -57,32 +52,99 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Next.Should().BeNull(); responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Links.Self.Should().Be($"/api/v1/people/{person.StringId}"); + responseDocument.SingleData.Links.Self.Should().Be($"/api/photoAlbums/{album.StringId}"); + responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be($"/api/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be($"/api/photoAlbums/{album.StringId}/photos"); + } + + [Fact] + public async Task Get_primary_resources_with_include_returns_relative_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = "/api/photoAlbums?include=photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); - responseDocument.SingleData.Relationships["todoItems"].Links.Self.Should().Be($"/api/v1/people/{person.StringId}/relationships/todoItems"); - responseDocument.SingleData.Relationships["todoItems"].Links.Related.Should().Be($"/api/v1/people/{person.StringId}/todoItems"); + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be("/api/photoAlbums?include=photos"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be("/api/photoAlbums?include=photos"); + responseDocument.Links.Last.Should().Be("/api/photoAlbums?include=photos"); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Self.Should().Be($"/api/photoAlbums/{album.StringId}"); + responseDocument.ManyData[0].Relationships["photos"].Links.Self.Should().Be($"/api/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.ManyData[0].Relationships["photos"].Links.Related.Should().Be($"/api/photoAlbums/{album.StringId}/photos"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"/api/photos/{album.Photos.ElementAt(0).StringId}"); + responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"/api/photos/{album.Photos.ElementAt(0).StringId}/relationships/album"); + responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"/api/photos/{album.Photos.ElementAt(0).StringId}/album"); } [Fact] - public async Task Get_primary_resources_with_include_returns_links() + public async Task Get_secondary_resource_returns_relative_links() { // Arrange - var person = new Person + var photo = _fakers.Photo.Generate(); + photo.Album = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => { - TodoItems = new HashSet - { - new TodoItem() - } - }; + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/photos/{photo.StringId}/album"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"/api/photos/{photo.StringId}/album"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"/api/photoAlbums/{photo.Album.StringId}"); + responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be($"/api/photoAlbums/{photo.Album.StringId}/relationships/photos"); + responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be($"/api/photoAlbums/{photo.Album.StringId}/photos"); + } + + [Fact] + public async Task Get_secondary_resources_returns_relative_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); - dbContext.People.Add(person); + dbContext.PhotoAlbums.Add(album); await dbContext.SaveChangesAsync(); }); - var route = "/api/v1/people?include=todoItems"; + var route = $"/api/photoAlbums/{album.StringId}/photos"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -90,56 +152,204 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be("/api/v1/people?include=todoItems"); + responseDocument.Links.Self.Should().Be($"/api/photoAlbums/{album.StringId}/photos"); responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be("/api/v1/people?include=todoItems"); - responseDocument.Links.Last.Should().Be("/api/v1/people?include=todoItems"); + responseDocument.Links.First.Should().Be($"/api/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Links.Self.Should().Be($"/api/v1/people/{person.StringId}"); + responseDocument.ManyData[0].Links.Self.Should().Be($"/api/photos/{album.Photos.ElementAt(0).StringId}"); + responseDocument.ManyData[0].Relationships["album"].Links.Self.Should().Be($"/api/photos/{album.Photos.ElementAt(0).StringId}/relationships/album"); + responseDocument.ManyData[0].Relationships["album"].Links.Related.Should().Be($"/api/photos/{album.Photos.ElementAt(0).StringId}/album"); + } - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be($"/api/v1/todoItems/{person.TodoItems.ElementAt(0).StringId}"); + [Fact] + public async Task Get_HasOne_relationship_returns_relative_links() + { + // Arrange + var photo = _fakers.Photo.Generate(); + photo.Album = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/photos/{photo.StringId}/relationships/album"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"/api/photos/{photo.StringId}/relationships/album"); + responseDocument.Links.Related.Should().Be($"/api/photos/{photo.StringId}/album"); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Should().BeNull(); + responseDocument.SingleData.Relationships.Should().BeNull(); } [Fact] - public async Task Get_HasMany_relationship_returns_links() + public async Task Get_HasMany_relationship_returns_relative_links() { // Arrange - var person = new Person + var album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/api/photoAlbums/{album.StringId}/relationships/photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"/api/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.Links.Related.Should().Be($"/api/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.First.Should().Be($"/api/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Should().BeNull(); + responseDocument.ManyData[0].Relationships.Should().BeNull(); + } + + [Fact] + public async Task Create_resource_with_side_effects_and_include_returns_relative_links() + { + // Arrange + var existingPhoto = _fakers.Photo.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(existingPhoto); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new { - TodoItems = new HashSet + data = new { - new TodoItem() + type = "photoAlbums", + relationships = new + { + photos = new + { + data = new[] + { + new + { + type = "photos", + id = existingPhoto.StringId + } + } + } + } } }; + var route = "/api/photoAlbums?include=photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.Links.Self.Should().Be("/api/photoAlbums?include=photos"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + var newAlbumId = responseDocument.SingleData.Id; + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"/api/photoAlbums/{newAlbumId}"); + responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be($"/api/photoAlbums/{newAlbumId}/relationships/photos"); + responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be($"/api/photoAlbums/{newAlbumId}/photos"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"/api/photos/{existingPhoto.StringId}"); + responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"/api/photos/{existingPhoto.StringId}/relationships/album"); + responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"/api/photos/{existingPhoto.StringId}/album"); + } + + [Fact] + public async Task Update_resource_with_side_effects_and_include_returns_relative_links() + { + // Arrange + var existingPhoto = _fakers.Photo.Generate(); + var existingAlbum = _fakers.PhotoAlbum.Generate(); + await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); - dbContext.People.Add(person); + dbContext.AddRange(existingPhoto, existingAlbum); await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/people/{person.StringId}/relationships/todoItems"; + var requestBody = new + { + data = new + { + type = "photos", + id = existingPhoto.StringId, + relationships = new + { + album = new + { + data = new + { + type = "photoAlbums", + id = existingAlbum.StringId + } + } + } + } + }; + + var route = $"/api/photos/{existingPhoto.StringId}?include=album"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.Self.Should().Be($"/api/v1/people/{person.StringId}/relationships/todoItems"); - responseDocument.Links.Related.Should().Be($"/api/v1/people/{person.StringId}/todoItems"); - responseDocument.Links.First.Should().Be($"/api/v1/people/{person.StringId}/relationships/todoItems"); + responseDocument.Links.Self.Should().Be($"/api/photos/{existingPhoto.StringId}?include=album"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Links.Should().BeNull(); + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"/api/photos/{existingPhoto.StringId}"); + responseDocument.SingleData.Relationships["album"].Links.Self.Should().Be($"/api/photos/{existingPhoto.StringId}/relationships/album"); + responseDocument.SingleData.Relationships["album"].Links.Related.Should().Be($"/api/photos/{existingPhoto.StringId}/album"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"/api/photoAlbums/{existingAlbum.StringId}"); + responseDocument.Included[0].Relationships["photos"].Links.Self.Should().Be($"/api/photoAlbums/{existingAlbum.StringId}/relationships/photos"); + responseDocument.Included[0].Relationships["photos"].Links.Related.Should().Be($"/api/photoAlbums/{existingAlbum.StringId}/photos"); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs new file mode 100644 index 0000000000..d731a0948a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs @@ -0,0 +1,355 @@ +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links +{ + public sealed class RelativeLinksWithoutNamespaceTests + : IClassFixture, LinksDbContext>> + { + private readonly IntegrationTestContext, LinksDbContext> _testContext; + private readonly LinksFakers _fakers = new LinksFakers(); + + public RelativeLinksWithoutNamespaceTests(IntegrationTestContext, LinksDbContext> testContext) + { + _testContext = testContext; + + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); + options.IncludeTotalResourceCount = true; + } + + [Fact] + public async Task Get_primary_resource_by_ID_returns_relative_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = "/photoAlbums/" + album.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"/photoAlbums/{album.StringId}"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"/photoAlbums/{album.StringId}"); + responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be($"/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be($"/photoAlbums/{album.StringId}/photos"); + } + + [Fact] + public async Task Get_primary_resources_with_include_returns_relative_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = "/photoAlbums?include=photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be("/photoAlbums?include=photos"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be("/photoAlbums?include=photos"); + responseDocument.Links.Last.Should().Be("/photoAlbums?include=photos"); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Self.Should().Be($"/photoAlbums/{album.StringId}"); + responseDocument.ManyData[0].Relationships["photos"].Links.Self.Should().Be($"/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.ManyData[0].Relationships["photos"].Links.Related.Should().Be($"/photoAlbums/{album.StringId}/photos"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"/photos/{album.Photos.ElementAt(0).StringId}"); + responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"/photos/{album.Photos.ElementAt(0).StringId}/relationships/album"); + responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"/photos/{album.Photos.ElementAt(0).StringId}/album"); + } + + [Fact] + public async Task Get_secondary_resource_returns_relative_links() + { + // Arrange + var photo = _fakers.Photo.Generate(); + photo.Album = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/photos/{photo.StringId}/album"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"/photos/{photo.StringId}/album"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"/photoAlbums/{photo.Album.StringId}"); + responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be($"/photoAlbums/{photo.Album.StringId}/relationships/photos"); + responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be($"/photoAlbums/{photo.Album.StringId}/photos"); + } + + [Fact] + public async Task Get_secondary_resources_returns_relative_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/photoAlbums/{album.StringId}/photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be($"/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Self.Should().Be($"/photos/{album.Photos.ElementAt(0).StringId}"); + responseDocument.ManyData[0].Relationships["album"].Links.Self.Should().Be($"/photos/{album.Photos.ElementAt(0).StringId}/relationships/album"); + responseDocument.ManyData[0].Relationships["album"].Links.Related.Should().Be($"/photos/{album.Photos.ElementAt(0).StringId}/album"); + } + + [Fact] + public async Task Get_HasOne_relationship_returns_relative_links() + { + // Arrange + var photo = _fakers.Photo.Generate(); + photo.Album = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/photos/{photo.StringId}/relationships/album"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"/photos/{photo.StringId}/relationships/album"); + responseDocument.Links.Related.Should().Be($"/photos/{photo.StringId}/album"); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Should().BeNull(); + responseDocument.SingleData.Relationships.Should().BeNull(); + } + + [Fact] + public async Task Get_HasMany_relationship_returns_relative_links() + { + // Arrange + var album = _fakers.PhotoAlbum.Generate(); + album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.PhotoAlbums.Add(album); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/photoAlbums/{album.StringId}/relationships/photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.Links.Related.Should().Be($"/photoAlbums/{album.StringId}/photos"); + responseDocument.Links.First.Should().Be($"/photoAlbums/{album.StringId}/relationships/photos"); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Should().BeNull(); + responseDocument.ManyData[0].Relationships.Should().BeNull(); + } + + [Fact] + public async Task Create_resource_with_side_effects_and_include_returns_relative_links() + { + // Arrange + var existingPhoto = _fakers.Photo.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(existingPhoto); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "photoAlbums", + relationships = new + { + photos = new + { + data = new[] + { + new + { + type = "photos", + id = existingPhoto.StringId + } + } + } + } + } + }; + + var route = "/photoAlbums?include=photos"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.Links.Self.Should().Be("/photoAlbums?include=photos"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + var newAlbumId = responseDocument.SingleData.Id; + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"/photoAlbums/{newAlbumId}"); + responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be($"/photoAlbums/{newAlbumId}/relationships/photos"); + responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be($"/photoAlbums/{newAlbumId}/photos"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"/photos/{existingPhoto.StringId}"); + responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be($"/photos/{existingPhoto.StringId}/relationships/album"); + responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be($"/photos/{existingPhoto.StringId}/album"); + } + + [Fact] + public async Task Update_resource_with_side_effects_and_include_returns_relative_links() + { + // Arrange + var existingPhoto = _fakers.Photo.Generate(); + var existingAlbum = _fakers.PhotoAlbum.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingPhoto, existingAlbum); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "photos", + id = existingPhoto.StringId, + relationships = new + { + album = new + { + data = new + { + type = "photoAlbums", + id = existingAlbum.StringId + } + } + } + } + }; + + var route = $"/photos/{existingPhoto.StringId}?include=album"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be($"/photos/{existingPhoto.StringId}?include=album"); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().BeNull(); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Self.Should().Be($"/photos/{existingPhoto.StringId}"); + responseDocument.SingleData.Relationships["album"].Links.Self.Should().Be($"/photos/{existingPhoto.StringId}/relationships/album"); + responseDocument.SingleData.Relationships["album"].Links.Related.Should().Be($"/photos/{existingPhoto.StringId}/album"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be($"/photoAlbums/{existingAlbum.StringId}"); + responseDocument.Included[0].Relationships["photos"].Links.Self.Should().Be($"/photoAlbums/{existingAlbum.StringId}/relationships/photos"); + responseDocument.Included[0].Relationships["photos"].Links.Related.Should().Be($"/photoAlbums/{existingAlbum.StringId}/photos"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs index 326e348ea9..5ceb8d18ac 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation diff --git a/test/JsonApiDotNetCoreExampleTests/Startups/AbsoluteLinksInApiNamespaceStartup.cs b/test/JsonApiDotNetCoreExampleTests/Startups/AbsoluteLinksInApiNamespaceStartup.cs new file mode 100644 index 0000000000..1f71c8b9e5 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Startups/AbsoluteLinksInApiNamespaceStartup.cs @@ -0,0 +1,24 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCoreExampleTests.IntegrationTests; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +namespace JsonApiDotNetCoreExampleTests.Startups +{ + public sealed class AbsoluteLinksInApiNamespaceStartup : TestableStartup + where TDbContext : DbContext + { + public AbsoluteLinksInApiNamespaceStartup(IConfiguration configuration) + : base(configuration) + { + } + + protected override void SetJsonApiOptions(JsonApiOptions options) + { + base.SetJsonApiOptions(options); + + options.Namespace = "api"; + options.UseRelativeLinks = false; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Startups/AbsoluteLinksNoNamespaceStartup.cs b/test/JsonApiDotNetCoreExampleTests/Startups/AbsoluteLinksNoNamespaceStartup.cs new file mode 100644 index 0000000000..19b76f9146 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Startups/AbsoluteLinksNoNamespaceStartup.cs @@ -0,0 +1,24 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCoreExampleTests.IntegrationTests; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +namespace JsonApiDotNetCoreExampleTests.Startups +{ + public sealed class AbsoluteLinksNoNamespaceStartup : TestableStartup + where TDbContext : DbContext + { + public AbsoluteLinksNoNamespaceStartup(IConfiguration configuration) + : base(configuration) + { + } + + protected override void SetJsonApiOptions(JsonApiOptions options) + { + base.SetJsonApiOptions(options); + + options.Namespace = null; + options.UseRelativeLinks = false; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationStartup.cs b/test/JsonApiDotNetCoreExampleTests/Startups/ModelStateValidationStartup.cs similarity index 78% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationStartup.cs rename to test/JsonApiDotNetCoreExampleTests/Startups/ModelStateValidationStartup.cs index 71c15c3234..8114edfb1c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationStartup.cs +++ b/test/JsonApiDotNetCoreExampleTests/Startups/ModelStateValidationStartup.cs @@ -1,13 +1,15 @@ using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCoreExampleTests.IntegrationTests; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +namespace JsonApiDotNetCoreExampleTests.Startups { public sealed class ModelStateValidationStartup : TestableStartup where TDbContext : DbContext { - public ModelStateValidationStartup(IConfiguration configuration) : base(configuration) + public ModelStateValidationStartup(IConfiguration configuration) + : base(configuration) { } diff --git a/test/JsonApiDotNetCoreExampleTests/Startups/RelativeLinksInApiNamespaceStartup.cs b/test/JsonApiDotNetCoreExampleTests/Startups/RelativeLinksInApiNamespaceStartup.cs new file mode 100644 index 0000000000..c38601a58e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Startups/RelativeLinksInApiNamespaceStartup.cs @@ -0,0 +1,24 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCoreExampleTests.IntegrationTests; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +namespace JsonApiDotNetCoreExampleTests.Startups +{ + public sealed class RelativeLinksInApiNamespaceStartup : TestableStartup + where TDbContext : DbContext + { + public RelativeLinksInApiNamespaceStartup(IConfiguration configuration) + : base(configuration) + { + } + + protected override void SetJsonApiOptions(JsonApiOptions options) + { + base.SetJsonApiOptions(options); + + options.Namespace = "api"; + options.UseRelativeLinks = true; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/Startups/RelativeLinksNoNamespaceStartup.cs b/test/JsonApiDotNetCoreExampleTests/Startups/RelativeLinksNoNamespaceStartup.cs new file mode 100644 index 0000000000..b07f3d56c6 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/Startups/RelativeLinksNoNamespaceStartup.cs @@ -0,0 +1,24 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCoreExampleTests.IntegrationTests; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; + +namespace JsonApiDotNetCoreExampleTests.Startups +{ + public sealed class RelativeLinksNoNamespaceStartup : TestableStartup + where TDbContext : DbContext + { + public RelativeLinksNoNamespaceStartup(IConfiguration configuration) + : base(configuration) + { + } + + protected override void SetJsonApiOptions(JsonApiOptions options) + { + base.SetJsonApiOptions(options); + + options.Namespace = null; + options.UseRelativeLinks = true; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/WebHostCollection.cs b/test/JsonApiDotNetCoreExampleTests/WebHostCollection.cs deleted file mode 100644 index c50654c30d..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/WebHostCollection.cs +++ /dev/null @@ -1,10 +0,0 @@ -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExampleTests.Acceptance; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests -{ - [CollectionDefinition("WebHostCollection")] - public class WebHostCollection : ICollectionFixture> - { } -} From 8b443d89f0a683c6b8c4920dd923c019755ee2e4 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 2 Feb 2021 17:17:16 +0100 Subject: [PATCH 087/123] Refactored tests for exception handling in serializer --- .../ThrowingResourcesController.cs | 18 -------- .../Data/AppDbContext.cs | 2 - .../Models/ThrowingResource.cs | 30 ------------ .../Acceptance/Spec/ThrowingResourceTests.cs | 46 ------------------- .../ExceptionHandling/ErrorDbContext.cs | 1 + .../ExceptionHandlerTests.cs | 40 ++++++++++++++++ .../ExceptionHandling/ThrowingArticle.cs | 14 ++++++ .../ThrowingArticlesController.cs | 16 +++++++ 8 files changed, 71 insertions(+), 96 deletions(-) delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Controllers/ThrowingResourcesController.cs delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Models/ThrowingResource.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ThrowingResourceTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ThrowingArticle.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ThrowingArticlesController.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/ThrowingResourcesController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/ThrowingResourcesController.cs deleted file mode 100644 index 8b662cde09..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/ThrowingResourcesController.cs +++ /dev/null @@ -1,18 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample.Models; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExample.Controllers -{ - public sealed class ThrowingResourcesController : JsonApiController - { - public ThrowingResourcesController( - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index 198169edcb..41e21feecc 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -28,8 +28,6 @@ public AppDbContext(DbContextOptions options, ISystemClock systemC protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.Entity(); - modelBuilder.Entity().HasBaseType(); modelBuilder.Entity() diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/ThrowingResource.cs b/src/Examples/JsonApiDotNetCoreExample/Models/ThrowingResource.cs deleted file mode 100644 index cf2f963e2a..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Models/ThrowingResource.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.Diagnostics; -using System.Linq; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization; - -namespace JsonApiDotNetCoreExample.Models -{ - public sealed class ThrowingResource : Identifiable - { - [Attr] - public string FailsOnSerialize - { - get - { - var isSerializingResponse = new StackTrace().GetFrames() - .Any(frame => frame.GetMethod().DeclaringType == typeof(JsonApiWriter)); - - if (isSerializingResponse) - { - throw new InvalidOperationException($"The value for the '{nameof(FailsOnSerialize)}' property is currently unavailable."); - } - - return string.Empty; - } - set { } - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ThrowingResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ThrowingResourceTests.cs deleted file mode 100644 index d597adc62c..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/ThrowingResourceTests.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Models; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - public class ThrowingResourceTests : FunctionalTestCollection - { - public ThrowingResourceTests(StandardApplicationFactory factory) : base(factory) - { - } - - [Fact] - public async Task GetThrowingResource_Fails() - { - // Arrange - var throwingResource = new ThrowingResource(); - _dbContext.Add(throwingResource); - await _dbContext.SaveChangesAsync(); - - // Act - var (body, response) = await Get($"/api/v1/throwingResources/{throwingResource.Id}"); - - // Assert - AssertEqualStatusCode(HttpStatusCode.InternalServerError, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.InternalServerError, errorDocument.Errors[0].StatusCode); - Assert.Equal("An unhandled error occurred while processing this request.", errorDocument.Errors[0].Title); - Assert.Equal("Exception has been thrown by the target of an invocation.", errorDocument.Errors[0].Detail); - - var stackTraceLines = - ((JArray) errorDocument.Errors[0].Meta.Data["stackTrace"]).Select(token => token.Value()); - - Assert.Contains(stackTraceLines, line => line.Contains( - "System.InvalidOperationException: The value for the 'FailsOnSerialize' property is currently unavailable.")); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs index 2e9f0cc3e8..9e4d0feab8 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs @@ -5,6 +5,7 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling public sealed class ErrorDbContext : DbContext { public DbSet ConsumerArticles { get; set; } + public DbSet ThrowingArticles { get; set; } public ErrorDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs index fca27f6857..1c96c52dcd 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCore.Serialization.Objects; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling @@ -79,7 +80,46 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Errors[0].Meta.Data["support"].Should().Be("Please contact us for info about similar articles at company@email.com."); loggerFactory.Logger.Messages.Should().HaveCount(1); + loggerFactory.Logger.Messages.Single().LogLevel.Should().Be(LogLevel.Warning); loggerFactory.Logger.Messages.Single().Text.Should().Contain("Article with code 'X123' is no longer available."); } + + [Fact] + public async Task Logs_and_produces_error_response_on_serialization_failure() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); + + var throwingArticle = new ThrowingArticle(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.ThrowingArticles.Add(throwingArticle); + await dbContext.SaveChangesAsync(); + }); + + var route = "/throwingArticles/" + throwingArticle.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.InternalServerError); + responseDocument.Errors[0].Title.Should().Be("An unhandled error occurred while processing this request."); + responseDocument.Errors[0].Detail.Should().Be("Exception has been thrown by the target of an invocation."); + + var stackTraceLines = + ((JArray) responseDocument.Errors[0].Meta.Data["stackTrace"]).Select(token => token.Value()); + + stackTraceLines.Should().ContainMatch("* System.InvalidOperationException: Article status could not be determined.*"); + + loggerFactory.Logger.Messages.Should().HaveCount(1); + loggerFactory.Logger.Messages.Single().LogLevel.Should().Be(LogLevel.Error); + loggerFactory.Logger.Messages.Single().Text.Should().Contain("Exception has been thrown by the target of an invocation."); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ThrowingArticle.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ThrowingArticle.cs new file mode 100644 index 0000000000..af4f7890ac --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ThrowingArticle.cs @@ -0,0 +1,14 @@ +using System; +using System.ComponentModel.DataAnnotations.Schema; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling +{ + public sealed class ThrowingArticle : Identifiable + { + [Attr] + [NotMapped] + public string Status => throw new InvalidOperationException("Article status could not be determined."); + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ThrowingArticlesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ThrowingArticlesController.cs new file mode 100644 index 0000000000..6616498f85 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ThrowingArticlesController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling +{ + public sealed class ThrowingArticlesController : JsonApiController + { + public ThrowingArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} From 64e09db76d6a34589e0b884d848bcd1f5a58ce89 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Tue, 2 Feb 2021 23:07:31 +0100 Subject: [PATCH 088/123] Refactored tests for serialization --- .../Acceptance/SerializationTests.cs | 122 ---- .../Helpers/Extensions/StringExtensions.cs | 10 - .../Links/AbsoluteLinksWithNamespaceTests.cs | 6 + .../AbsoluteLinksWithoutNamespaceTests.cs | 6 + .../IntegrationTests/Links/Photo.cs | 3 - .../IntegrationTests/Links/PhotoAlbum.cs | 3 - .../Links/RelativeLinksWithNamespaceTests.cs | 6 + .../RelativeLinksWithoutNamespaceTests.cs | 6 + .../Meta/ResponseMetaTests.cs | 10 +- .../ObjectAssertionsExtensions.cs | 22 + .../IntegrationTests/Serialization/Meeting.cs | 42 ++ .../Serialization/MeetingAttendee.cs | 15 + .../MeetingAttendeesController.cs | 17 + .../Serialization/MeetingLocation.cs | 13 + .../Serialization/MeetingsController.cs | 17 + .../Serialization/SerializationDbContext.cs | 15 + .../Serialization/SerializationFakers.cs | 41 ++ .../Serialization/SerializationTests.cs | 555 ++++++++++++++++++ .../NeverSameResourceChangeTracker.cs | 28 + 19 files changed, 792 insertions(+), 145 deletions(-) delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/StringExtensions.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/Meeting.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/MeetingAttendee.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/MeetingAttendeesController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/MeetingLocation.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/MeetingsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationFakers.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/NeverSameResourceChangeTracker.cs diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs deleted file mode 100644 index 214f8adbda..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/SerializationTests.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.Acceptance.Spec; -using JsonApiDotNetCoreExampleTests.Helpers.Extensions; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - public sealed class SerializationTests : FunctionalTestCollection - { - public SerializationTests(StandardApplicationFactory factory) - : base(factory) - { - } - - [Fact] - public async Task When_getting_person_it_must_match_JSON_text() - { - // Arrange - var person = new Person - { - Id = 123, - FirstName = "John", - LastName = "Doe", - Age = 57, - Gender = Gender.Male, - Category = "Family" - }; - - await _dbContext.ClearTableAsync(); - _dbContext.People.Add(person); - await _dbContext.SaveChangesAsync(); - - var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/people/" + person.Id); - - // Act - var response = await _factory.Client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - var bodyText = await response.Content.ReadAsStringAsync(); - var json = JsonConvert.DeserializeObject(bodyText).ToString(); - - var expected = @"{ - ""links"": { - ""self"": ""http://localhost/api/v1/people/123"" - }, - ""data"": { - ""type"": ""people"", - ""id"": ""123"", - ""attributes"": { - ""firstName"": ""John"", - ""initials"": ""J"", - ""lastName"": ""Doe"", - ""the-Age"": 57, - ""gender"": ""Male"", - ""category"": ""Family"" - }, - ""relationships"": { - ""todoItems"": { - ""links"": { - ""self"": ""http://localhost/api/v1/people/123/relationships/todoItems"", - ""related"": ""http://localhost/api/v1/people/123/todoItems"" - } - }, - ""assignedTodoItems"": { - ""links"": { - ""self"": ""http://localhost/api/v1/people/123/relationships/assignedTodoItems"", - ""related"": ""http://localhost/api/v1/people/123/assignedTodoItems"" - } - }, - ""todoCollections"": { - ""links"": { - ""self"": ""http://localhost/api/v1/people/123/relationships/todoCollections"", - ""related"": ""http://localhost/api/v1/people/123/todoCollections"" - } - }, - ""role"": { - ""links"": { - ""self"": ""http://localhost/api/v1/people/123/relationships/role"", - ""related"": ""http://localhost/api/v1/people/123/role"" - } - }, - ""oneToOneTodoItem"": { - ""links"": { - ""self"": ""http://localhost/api/v1/people/123/relationships/oneToOneTodoItem"", - ""related"": ""http://localhost/api/v1/people/123/oneToOneTodoItem"" - } - }, - ""stakeHolderTodoItem"": { - ""links"": { - ""self"": ""http://localhost/api/v1/people/123/relationships/stakeHolderTodoItem"", - ""related"": ""http://localhost/api/v1/people/123/stakeHolderTodoItem"" - } - }, - ""unIncludeableItem"": { - ""links"": { - ""self"": ""http://localhost/api/v1/people/123/relationships/unIncludeableItem"", - ""related"": ""http://localhost/api/v1/people/123/unIncludeableItem"" - } - }, - ""passport"": { - ""links"": { - ""self"": ""http://localhost/api/v1/people/123/relationships/passport"", - ""related"": ""http://localhost/api/v1/people/123/passport"" - } - } - }, - ""links"": { - ""self"": ""http://localhost/api/v1/people/123"" - } - } -}"; - Assert.Equal(expected.NormalizeLineEndings(), json.NormalizeLineEndings()); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/StringExtensions.cs b/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/StringExtensions.cs deleted file mode 100644 index 8cccc0c8e2..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Helpers/Extensions/StringExtensions.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace JsonApiDotNetCoreExampleTests.Helpers.Extensions -{ - public static class StringExtensions - { - public static string NormalizeLineEndings(this string text) - { - return text.Replace("\r\n", "\n").Replace("\r", "\n"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs index 337668586f..692bc950df 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; @@ -20,6 +21,11 @@ public AbsoluteLinksWithNamespaceTests(IntegrationTestContext + { + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); options.IncludeTotalResourceCount = true; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs index ee4091ebc2..56949c9b99 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; @@ -20,6 +21,11 @@ public AbsoluteLinksWithoutNamespaceTests(IntegrationTestContext + { + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); options.IncludeTotalResourceCount = true; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/Photo.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/Photo.cs index da964b4392..8af6915e1d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/Photo.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/Photo.cs @@ -9,9 +9,6 @@ public sealed class Photo : Identifiable [Attr] public string Url { get; set; } - [Attr] - public Guid ConcurrencyToken => Guid.NewGuid(); - [HasOne] public PhotoAlbum Album { get; set; } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoAlbum.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoAlbum.cs index 17087913d1..d5a6b448e3 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoAlbum.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoAlbum.cs @@ -10,9 +10,6 @@ public sealed class PhotoAlbum : Identifiable [Attr] public string Name { get; set; } - [Attr] - public Guid ConcurrencyToken => Guid.NewGuid(); - [HasMany] public ISet Photos { get; set; } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs index 9dcc0009d4..e22eef9ce1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; @@ -20,6 +21,11 @@ public RelativeLinksWithNamespaceTests(IntegrationTestContext + { + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); options.IncludeTotalResourceCount = true; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs index d731a0948a..e80bc9b17b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; @@ -20,6 +21,11 @@ public RelativeLinksWithoutNamespaceTests(IntegrationTestContext + { + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); options.IncludeTotalResourceCount = true; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs index 353ae641de..73991d509f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs @@ -7,9 +7,7 @@ using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.Helpers.Extensions; using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json.Linq; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta @@ -40,12 +38,12 @@ public async Task Registered_IResponseMeta_Adds_TopLevel_Meta() var route = "/api/v1/people"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - var expected = @"{ + responseDocument.Should().BeJson(@"{ ""meta"": { ""license"": ""MIT"", ""projectUrl"": ""https://github.com/json-api-dotnet/JsonApiDotNetCore/"", @@ -61,9 +59,7 @@ public async Task Registered_IResponseMeta_Adds_TopLevel_Meta() ""first"": ""http://localhost/api/v1/people"" }, ""data"": [] -}"; - - responseDocument.ToString().NormalizeLineEndings().Should().Be(expected.NormalizeLineEndings()); +}"); } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs index d342daf992..e5a6c4e1e1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs @@ -1,11 +1,18 @@ using System; using FluentAssertions; using FluentAssertions.Primitives; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; namespace JsonApiDotNetCoreExampleTests.IntegrationTests { public static class ObjectAssertionsExtensions { + private static readonly JsonSerializerSettings _deserializationSettings = new JsonSerializerSettings + { + Formatting = Formatting.Indented + }; + /// /// Used to assert on a (nullable) or property, /// whose value is returned as in JSON:API response body @@ -29,5 +36,20 @@ public static void BeCloseTo(this ObjectAssertions source, DateTimeOffset? expec value.Should().BeCloseTo(expected.Value, because: because, becauseArgs: becauseArgs); } } + + /// + /// Used to assert on a JSON-formatted string, ignoring differences in insignificant whitespace and line endings. + /// + public static void BeJson(this StringAssertions source, string expected, string because = "", + params object[] becauseArgs) + { + var sourceToken = JsonConvert.DeserializeObject(source.Subject, _deserializationSettings); + var expectedToken = JsonConvert.DeserializeObject(expected, _deserializationSettings); + + string sourceText = sourceToken?.ToString(); + string expectedText = expectedToken?.ToString(); + + sourceText.Should().Be(expectedText, because, becauseArgs); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/Meeting.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/Meeting.cs new file mode 100644 index 0000000000..6a3092004f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/Meeting.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Serialization +{ + public sealed class Meeting : Identifiable + { + [Attr] + public string Title { get; set; } + + [Attr] + public DateTimeOffset StartTime { get; set; } + + [Attr] + public TimeSpan Duration { get; set; } + + [Attr] + [NotMapped] + public MeetingLocation Location + { + get => new MeetingLocation + { + Latitude = Latitude, + Longitude = Longitude + }; + set + { + Latitude = value?.Latitude ?? double.NaN; + Longitude = value?.Longitude ?? double.NaN; + } + } + + public double Latitude { get; set; } + public double Longitude { get; set; } + + [HasMany] + public IList Attendees { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/MeetingAttendee.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/MeetingAttendee.cs new file mode 100644 index 0000000000..dd79ed029b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/MeetingAttendee.cs @@ -0,0 +1,15 @@ +using System; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Serialization +{ + public sealed class MeetingAttendee : Identifiable + { + [Attr] + public string DisplayName { get; set; } + + [HasOne] + public Meeting Meeting { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/MeetingAttendeesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/MeetingAttendeesController.cs new file mode 100644 index 0000000000..c1595a0109 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/MeetingAttendeesController.cs @@ -0,0 +1,17 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Serialization +{ + public sealed class MeetingAttendeesController : JsonApiController + { + public MeetingAttendeesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/MeetingLocation.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/MeetingLocation.cs new file mode 100644 index 0000000000..55cc6be6be --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/MeetingLocation.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Serialization +{ + public sealed class MeetingLocation + { + [JsonProperty("lat")] + public double Latitude { get; set; } + + [JsonProperty("lng")] + public double Longitude { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/MeetingsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/MeetingsController.cs new file mode 100644 index 0000000000..2e1cffbaf2 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/MeetingsController.cs @@ -0,0 +1,17 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Serialization +{ + public sealed class MeetingsController : JsonApiController + { + public MeetingsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationDbContext.cs new file mode 100644 index 0000000000..730ea37d42 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationDbContext.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Serialization +{ + public sealed class SerializationDbContext : DbContext + { + public DbSet Meetings { get; set; } + public DbSet Attendees { get; set; } + + public SerializationDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationFakers.cs new file mode 100644 index 0000000000..f884e23089 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationFakers.cs @@ -0,0 +1,41 @@ +using System; +using Bogus; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Serialization +{ + internal sealed class SerializationFakers : FakerContainer + { + private static readonly TimeSpan[] _meetingDurations = + { + TimeSpan.FromMinutes(15), + TimeSpan.FromMinutes(30), + TimeSpan.FromMinutes(45), + TimeSpan.FromMinutes(60) + }; + + private readonly Lazy> _lazyMeetingFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(meeting => meeting.Title, f => f.Lorem.Word()) + .RuleFor(meeting => meeting.StartTime, f => TruncateToWholeMilliseconds(f.Date.FutureOffset())) + .RuleFor(meeting => meeting.Duration, f => f.PickRandom(_meetingDurations)) + .RuleFor(meeting => meeting.Latitude, f => f.Address.Latitude()) + .RuleFor(meeting => meeting.Longitude, f => f.Address.Longitude())); + + private readonly Lazy> _lazyMeetingAttendeeFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(attendee => attendee.DisplayName, f => f.Random.Utf16String())); + + public Faker Meeting => _lazyMeetingFaker.Value; + public Faker MeetingAttendee => _lazyMeetingAttendeeFaker.Value; + + private static DateTimeOffset TruncateToWholeMilliseconds(DateTimeOffset value) + { + var ticksToSubtract = value.DateTime.Ticks % TimeSpan.TicksPerMillisecond; + var ticksInWholeMilliseconds = value.DateTime.Ticks - ticksToSubtract; + + return new DateTimeOffset(new DateTime(ticksInWholeMilliseconds), value.Offset); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationTests.cs new file mode 100644 index 0000000000..62a402f564 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationTests.cs @@ -0,0 +1,555 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Serialization +{ + public sealed class SerializationTests + : IClassFixture, SerializationDbContext>> + { + private readonly IntegrationTestContext, SerializationDbContext> _testContext; + private readonly SerializationFakers _fakers = new SerializationFakers(); + + public SerializationTests(IntegrationTestContext, SerializationDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); + + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); + options.IncludeExceptionStackTraceInErrors = false; + options.AllowClientGeneratedIds = true; + } + + [Fact] + public async Task Can_get_primary_resources_with_include() + { + // Arrange + var meetings = _fakers.Meeting.Generate(1); + meetings[0].Attendees = _fakers.MeetingAttendee.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Meetings.AddRange(meetings); + await dbContext.SaveChangesAsync(); + }); + + var route = "/meetings?include=attendees"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Should().BeJson(@"{ + ""links"": { + ""self"": ""http://localhost/meetings?include=attendees"", + ""first"": ""http://localhost/meetings?include=attendees"" + }, + ""data"": [ + { + ""type"": ""meetings"", + ""id"": """ + meetings[0].StringId + @""", + ""attributes"": { + ""title"": """ + meetings[0].Title + @""", + ""startTime"": """ + meetings[0].StartTime.ToString("O") + @""", + ""duration"": """ + meetings[0].Duration + @""", + ""location"": { + ""lat"": " + meetings[0].Location.Latitude.ToString(CultureInfo.InvariantCulture) + @", + ""lng"": " + meetings[0].Location.Longitude.ToString(CultureInfo.InvariantCulture) + @" + } + }, + ""relationships"": { + ""attendees"": { + ""links"": { + ""self"": ""http://localhost/meetings/" + meetings[0].StringId + @"/relationships/attendees"", + ""related"": ""http://localhost/meetings/" + meetings[0].StringId + @"/attendees"" + }, + ""data"": [ + { + ""type"": ""meetingAttendees"", + ""id"": """ + meetings[0].Attendees[0].StringId + @""" + } + ] + } + }, + ""links"": { + ""self"": ""http://localhost/meetings/" + meetings[0].StringId + @""" + } + } + ], + ""included"": [ + { + ""type"": ""meetingAttendees"", + ""id"": """ + meetings[0].Attendees[0].StringId + @""", + ""attributes"": { + ""displayName"": """ + meetings[0].Attendees[0].DisplayName + @""" + }, + ""relationships"": { + ""meeting"": { + ""links"": { + ""self"": ""http://localhost/meetingAttendees/" + meetings[0].Attendees[0].StringId + @"/relationships/meeting"", + ""related"": ""http://localhost/meetingAttendees/" + meetings[0].Attendees[0].StringId + @"/meeting"" + } + } + }, + ""links"": { + ""self"": ""http://localhost/meetingAttendees/" + meetings[0].Attendees[0].StringId + @""" + } + } + ] +}"); + } + + [Fact] + public async Task Can_get_primary_resource_by_ID() + { + // Arrange + var meeting = _fakers.Meeting.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Meetings.Add(meeting); + await dbContext.SaveChangesAsync(); + }); + + var route = "/meetings/" + meeting.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Should().BeJson(@"{ + ""links"": { + ""self"": ""http://localhost/meetings/" + meeting.StringId + @""" + }, + ""data"": { + ""type"": ""meetings"", + ""id"": """ + meeting.StringId + @""", + ""attributes"": { + ""title"": """ + meeting.Title + @""", + ""startTime"": """ + meeting.StartTime.ToString("O") + @""", + ""duration"": """ + meeting.Duration + @""", + ""location"": { + ""lat"": " + meeting.Location.Latitude.ToString(CultureInfo.InvariantCulture) + @", + ""lng"": " + meeting.Location.Longitude.ToString(CultureInfo.InvariantCulture) + @" + } + }, + ""relationships"": { + ""attendees"": { + ""links"": { + ""self"": ""http://localhost/meetings/" + meeting.StringId + @"/relationships/attendees"", + ""related"": ""http://localhost/meetings/" + meeting.StringId + @"/attendees"" + } + } + }, + ""links"": { + ""self"": ""http://localhost/meetings/" + meeting.StringId + @""" + } + } +}"); + } + + [Fact] + public async Task Cannot_get_unknown_primary_resource_by_ID() + { + // Arrange + var unknownId = Guid.NewGuid(); + + var route = "/meetings/" + unknownId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + var jObject = JsonConvert.DeserializeObject(responseDocument); + var errorId = jObject["errors"].Should().NotBeNull().And.Subject.Select(element => (string) element["id"]).Single(); + + responseDocument.Should().BeJson(@"{ + ""errors"": [ + { + ""id"": """ + errorId + @""", + ""status"": ""404"", + ""title"": ""The requested resource does not exist."", + ""detail"": ""Resource of type 'meetings' with ID '" + unknownId + @"' does not exist."" + } + ] +}"); + } + + [Fact] + public async Task Can_get_secondary_resource() + { + // Arrange + var attendee = _fakers.MeetingAttendee.Generate(); + attendee.Meeting = _fakers.Meeting.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Attendees.Add(attendee); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/meetingAttendees/{attendee.StringId}/meeting"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Should().BeJson(@"{ + ""links"": { + ""self"": ""http://localhost/meetingAttendees/" + attendee.StringId + @"/meeting"" + }, + ""data"": { + ""type"": ""meetings"", + ""id"": """ + attendee.Meeting.StringId + @""", + ""attributes"": { + ""title"": """ + attendee.Meeting.Title + @""", + ""startTime"": """ + attendee.Meeting.StartTime.ToString("O") + @""", + ""duration"": """ + attendee.Meeting.Duration + @""", + ""location"": { + ""lat"": " + attendee.Meeting.Location.Latitude.ToString(CultureInfo.InvariantCulture) + @", + ""lng"": " + attendee.Meeting.Location.Longitude.ToString(CultureInfo.InvariantCulture) + @" + } + }, + ""relationships"": { + ""attendees"": { + ""links"": { + ""self"": ""http://localhost/meetings/" + attendee.Meeting.StringId + @"/relationships/attendees"", + ""related"": ""http://localhost/meetings/" + attendee.Meeting.StringId + @"/attendees"" + } + } + }, + ""links"": { + ""self"": ""http://localhost/meetings/" + attendee.Meeting.StringId + @""" + } + } +}"); + } + + [Fact] + public async Task Can_get_unknown_secondary_resource() + { + // Arrange + var attendee = _fakers.MeetingAttendee.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Attendees.Add(attendee); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/meetingAttendees/{attendee.StringId}/meeting"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Should().BeJson(@"{ + ""links"": { + ""self"": ""http://localhost/meetingAttendees/" + attendee.StringId + @"/meeting"" + }, + ""data"": null +}"); + } + + [Fact] + public async Task Can_get_secondary_resources() + { + // Arrange + var meeting = _fakers.Meeting.Generate(); + meeting.Attendees = _fakers.MeetingAttendee.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Meetings.Add(meeting); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/meetings/{meeting.StringId}/attendees"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Should().BeJson(@"{ + ""links"": { + ""self"": ""http://localhost/meetings/" + meeting.StringId + @"/attendees"", + ""first"": ""http://localhost/meetings/" + meeting.StringId + @"/attendees"" + }, + ""data"": [ + { + ""type"": ""meetingAttendees"", + ""id"": """ + meeting.Attendees[0].StringId + @""", + ""attributes"": { + ""displayName"": """ + meeting.Attendees[0].DisplayName + @""" + }, + ""relationships"": { + ""meeting"": { + ""links"": { + ""self"": ""http://localhost/meetingAttendees/" + meeting.Attendees[0].StringId + @"/relationships/meeting"", + ""related"": ""http://localhost/meetingAttendees/" + meeting.Attendees[0].StringId + @"/meeting"" + } + } + }, + ""links"": { + ""self"": ""http://localhost/meetingAttendees/" + meeting.Attendees[0].StringId + @""" + } + } + ] +}"); + } + + [Fact] + public async Task Can_get_unknown_secondary_resources() + { + // Arrange + var meeting = _fakers.Meeting.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Meetings.Add(meeting); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/meetings/{meeting.StringId}/attendees"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Should().BeJson(@"{ + ""links"": { + ""self"": ""http://localhost/meetings/" + meeting.StringId + @"/attendees"", + ""first"": ""http://localhost/meetings/" + meeting.StringId + @"/attendees"" + }, + ""data"": [] +}"); + } + + [Fact] + public async Task Can_get_HasOne_relationship() + { + // Arrange + var attendee = _fakers.MeetingAttendee.Generate(); + attendee.Meeting = _fakers.Meeting.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Attendees.Add(attendee); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/meetingAttendees/{attendee.StringId}/relationships/meeting"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Should().BeJson(@"{ + ""links"": { + ""self"": ""http://localhost/meetingAttendees/" + attendee.StringId + @"/relationships/meeting"", + ""related"": ""http://localhost/meetingAttendees/" + attendee.StringId + @"/meeting"" + }, + ""data"": { + ""type"": ""meetings"", + ""id"": """ + attendee.Meeting.StringId + @""" + } +}"); + } + + [Fact] + public async Task Can_get_HasMany_relationship() + { + // Arrange + var meeting = _fakers.Meeting.Generate(); + meeting.Attendees = _fakers.MeetingAttendee.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Meetings.Add(meeting); + await dbContext.SaveChangesAsync(); + }); + + var route = $"/meetings/{meeting.StringId}/relationships/attendees"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + var meetingIds = meeting.Attendees.Select(attendee => attendee.StringId).OrderBy(id => id).ToArray(); + + responseDocument.Should().BeJson(@"{ + ""links"": { + ""self"": ""http://localhost/meetings/" + meeting.StringId + @"/relationships/attendees"", + ""related"": ""http://localhost/meetings/" + meeting.StringId + @"/attendees"", + ""first"": ""http://localhost/meetings/" + meeting.StringId + @"/relationships/attendees"" + }, + ""data"": [ + { + ""type"": ""meetingAttendees"", + ""id"": """ + meetingIds[0] + @""" + }, + { + ""type"": ""meetingAttendees"", + ""id"": """ + meetingIds[1] + @""" + } + ] +}"); + } + + [Fact] + public async Task Can_create_resource_with_side_effects() + { + // Arrange + var newMeeting = _fakers.Meeting.Generate(); + newMeeting.Id = Guid.NewGuid(); + + var requestBody = new + { + data = new + { + type = "meetings", + id = newMeeting.StringId, + attributes = new + { + title = newMeeting.Title, + startTime = newMeeting.StartTime, + duration = newMeeting.Duration, + location = new + { + lat = newMeeting.Latitude, + lng = newMeeting.Longitude + } + } + } + }; + + var route = "/meetings"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.Should().BeJson(@"{ + ""links"": { + ""self"": ""http://localhost/meetings"" + }, + ""data"": { + ""type"": ""meetings"", + ""id"": """ + newMeeting.StringId + @""", + ""attributes"": { + ""title"": """ + newMeeting.Title + @""", + ""startTime"": """ + newMeeting.StartTime.ToString("O") + @""", + ""duration"": """ + newMeeting.Duration + @""", + ""location"": { + ""lat"": " + newMeeting.Location.Latitude.ToString(CultureInfo.InvariantCulture) + @", + ""lng"": " + newMeeting.Location.Longitude.ToString(CultureInfo.InvariantCulture) + @" + } + }, + ""relationships"": { + ""attendees"": { + ""links"": { + ""self"": ""http://localhost/meetings/" + newMeeting.StringId + @"/relationships/attendees"", + ""related"": ""http://localhost/meetings/" + newMeeting.StringId + @"/attendees"" + } + } + }, + ""links"": { + ""self"": ""http://localhost/meetings/" + newMeeting.StringId + @""" + } + } +}"); + } + + [Fact] + public async Task Can_update_resource_with_side_effects() + { + // Arrange + var existingAttendee = _fakers.MeetingAttendee.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Attendees.Add(existingAttendee); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "meetingAttendees", + id = existingAttendee.StringId, + attributes = new + { + displayName = existingAttendee.DisplayName + } + } + }; + + var route = "/meetingAttendees/" + existingAttendee.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Should().BeJson(@"{ + ""links"": { + ""self"": ""http://localhost/meetingAttendees/" + existingAttendee.StringId + @""" + }, + ""data"": { + ""type"": ""meetingAttendees"", + ""id"": """ + existingAttendee.StringId + @""", + ""attributes"": { + ""displayName"": """ + existingAttendee.DisplayName + @""" + }, + ""relationships"": { + ""meeting"": { + ""links"": { + ""self"": ""http://localhost/meetingAttendees/" + existingAttendee.StringId + @"/relationships/meeting"", + ""related"": ""http://localhost/meetingAttendees/" + existingAttendee.StringId + @"/meeting"" + } + } + }, + ""links"": { + ""self"": ""http://localhost/meetingAttendees/" + existingAttendee.StringId + @""" + } + } +}"); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/NeverSameResourceChangeTracker.cs b/test/JsonApiDotNetCoreExampleTests/NeverSameResourceChangeTracker.cs new file mode 100644 index 0000000000..b518811dfd --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/NeverSameResourceChangeTracker.cs @@ -0,0 +1,28 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreExampleTests +{ + /// + /// Ensures the resource attributes are returned when creating/updating a resource. + /// + internal sealed class NeverSameResourceChangeTracker : IResourceChangeTracker + where TResource : class, IIdentifiable + { + public void SetInitiallyStoredAttributeValues(TResource resource) + { + } + + public void SetRequestedAttributeValues(TResource resource) + { + } + + public void SetFinallyStoredAttributeValues(TResource resource) + { + } + + public bool HasImplicitChanges() + { + return true; + } + } +} From 8bf098940766d9a6cf7123636a0cc55bce073291 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 3 Feb 2021 00:49:41 +0100 Subject: [PATCH 089/123] Refactored tests for resource hooks --- ...finition.cs => TodoItemHooksDefinition.cs} | 4 +- .../JsonApiDotNetCoreExample/Dockerfile | 13 - .../JsonApiDotNetCoreExample/Models/User.cs | 9 +- .../Services/CustomArticleService.cs | 37 - .../Startups/TestStartup.cs | 47 -- .../ResourceDefinitionTests.cs | 611 -------------- .../Spec/FunctionalTestCollection.cs | 116 --- .../Factories/CustomApplicationFactoryBase.cs | 38 - .../ResourceHooksApplicationFactory.cs | 33 - .../Factories/StandardApplicationFactory.cs | 19 - .../Helpers/Models/TodoItemClient.cs | 34 - .../ResourceHooks/HookFakers.cs | 79 ++ .../ResourceHooks/ResourceHookTests.cs | 754 ++++++++++++++++++ .../ResourceHooks/ResourceHooksStartup.cs | 36 + .../ServiceCollectionExtensions.cs | 15 + 15 files changed, 892 insertions(+), 953 deletions(-) rename src/Examples/JsonApiDotNetCoreExample/Definitions/{TodoHooksDefinition.cs => TodoItemHooksDefinition.cs} (86%) delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Dockerfile delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Startups/TestStartup.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FunctionalTestCollection.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Factories/CustomApplicationFactoryBase.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Factories/ResourceHooksApplicationFactory.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Factories/StandardApplicationFactory.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/Helpers/Models/TodoItemClient.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/HookFakers.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHooksStartup.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ServiceCollectionExtensions.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoHooksDefinition.cs b/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemHooksDefinition.cs similarity index 86% rename from src/Examples/JsonApiDotNetCoreExample/Definitions/TodoHooksDefinition.cs rename to src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemHooksDefinition.cs index c3fc7af2e5..33fa998337 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoHooksDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemHooksDefinition.cs @@ -9,9 +9,9 @@ namespace JsonApiDotNetCoreExample.Definitions { - public class TodoHooksDefinition : LockableHooksDefinition + public class TodoItemHooksDefinition : LockableHooksDefinition { - public TodoHooksDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } + public TodoItemHooksDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } public override void BeforeRead(ResourcePipeline pipeline, bool isIncluded = false, string stringId = null) { diff --git a/src/Examples/JsonApiDotNetCoreExample/Dockerfile b/src/Examples/JsonApiDotNetCoreExample/Dockerfile deleted file mode 100644 index c5a5d90ff4..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM microsoft/dotnet:latest - -COPY . /app - -WORKDIR /app - -RUN ["dotnet", "restore"] - -RUN ["dotnet", "build"] - -EXPOSE 14140/tcp - -CMD ["dotnet", "run", "--server.urls", "http://*:14140"] diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/User.cs b/src/Examples/JsonApiDotNetCoreExample/Models/User.cs index fde27f2922..dea3f26be5 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/User.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/User.cs @@ -11,7 +11,8 @@ public class User : Identifiable private readonly ISystemClock _systemClock; private string _password; - [Attr] public string UserName { get; set; } + [Attr] + public string UserName { get; set; } [Attr(Capabilities = AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange)] public string Password @@ -27,7 +28,8 @@ public string Password } } - [Attr] public DateTime LastPasswordChange { get; set; } + [Attr] + public DateTime LastPasswordChange { get; set; } public User(AppDbContext appDbContext) { @@ -37,7 +39,8 @@ public User(AppDbContext appDbContext) public sealed class SuperUser : User { - [Attr] public int SecurityLevel { get; set; } + [Attr] + public int SecurityLevel { get; set; } public SuperUser(AppDbContext appDbContext) : base(appDbContext) { diff --git a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs b/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs deleted file mode 100644 index b71a1e9e57..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Services/CustomArticleService.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Repositories; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample.Models; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExample.Services -{ - public class CustomArticleService : JsonApiResourceService
- { - public CustomArticleService( - IResourceRepositoryAccessor repositoryAccessor, - IQueryLayerComposer queryLayerComposer, - IPaginationContext paginationContext, - IJsonApiOptions options, - ILoggerFactory loggerFactory, - IJsonApiRequest request, - IResourceChangeTracker
resourceChangeTracker, - IResourceHookExecutorFacade hookExecutor) - : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, - resourceChangeTracker, hookExecutor) - { } - - public override async Task
GetAsync(int id, CancellationToken cancellationToken) - { - var resource = await base.GetAsync(id, cancellationToken); - resource.Caption = "None for you Glen Coco"; - return resource; - } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/TestStartup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/TestStartup.cs deleted file mode 100644 index e1af39084c..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/TestStartup.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -namespace JsonApiDotNetCoreExample -{ - public class TestStartup : Startup - { - public TestStartup(IConfiguration configuration) : base(configuration) - { - } - - protected override void ConfigureClock(IServiceCollection services) - { - services.AddSingleton(); - } - - /// - /// Advances the clock one second each time the current time is requested. - /// - private class TickingSystemClock : ISystemClock - { - private DateTimeOffset _utcNow; - - public DateTimeOffset UtcNow - { - get - { - var utcNow = _utcNow; - _utcNow = _utcNow.AddSeconds(1); - return utcNow; - } - } - - public TickingSystemClock() - : this(new DateTimeOffset(new DateTime(2000, 1, 1))) - { - } - - public TickingSystemClock(DateTimeOffset utcNow) - { - _utcNow = utcNow; - } - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs deleted file mode 100644 index ac589aa0a8..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/ResourceDefinitions/ResourceDefinitionTests.cs +++ /dev/null @@ -1,611 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using Bogus; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.Acceptance.Spec; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; -using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; - -namespace JsonApiDotNetCoreExampleTests.Acceptance -{ - public sealed class ResourceDefinitionTests : FunctionalTestCollection - { - private readonly Faker _userFaker; - private readonly Faker _todoItemFaker; - private readonly Faker _personFaker; - private readonly Faker
_articleFaker; - private readonly Faker _authorFaker; - private readonly Faker _tagFaker; - - public ResourceDefinitionTests(ResourceHooksApplicationFactory factory) : base(factory) - { - _authorFaker = new Faker() - .RuleFor(a => a.LastName, f => f.Random.Words(2)); - - _articleFaker = new Faker
() - .RuleFor(a => a.Caption, f => f.Random.AlphaNumeric(10)) - .RuleFor(a => a.Author, f => _authorFaker.Generate()); - - _userFaker = new Faker() - .CustomInstantiator(f => new User(_dbContext)) - .RuleFor(u => u.UserName, f => f.Internet.UserName()) - .RuleFor(u => u.Password, f => f.Internet.Password()); - - _todoItemFaker = new Faker() - .RuleFor(t => t.Description, f => f.Lorem.Sentence()) - .RuleFor(t => t.Ordinal, f => f.Random.Number()) - .RuleFor(t => t.CreatedDate, f => f.Date.Past()); - - _personFaker = new Faker() - .RuleFor(p => p.FirstName, f => f.Name.FirstName()) - .RuleFor(p => p.LastName, f => f.Name.LastName()); - - _tagFaker = new Faker() - .CustomInstantiator(f => new Tag()) - .RuleFor(a => a.Name, f => f.Random.AlphaNumeric(10)); - - var options = (JsonApiOptions) _factory.Services.GetRequiredService(); - options.DisableTopPagination = false; - options.DisableChildrenPagination = false; - } - - [Fact] - public async Task Can_Create_User_With_Password() - { - // Arrange - var user = _userFaker.Generate(); - - var serializer = GetSerializer(p => new { p.Password, p.UserName }); - - var httpMethod = new HttpMethod("POST"); - var route = "/api/v1/users"; - - var request = new HttpRequestMessage(httpMethod, route) - { - Content = new StringContent(serializer.Serialize(user)) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - - // response assertions - var body = await response.Content.ReadAsStringAsync(); - var returnedUser = _deserializer.DeserializeSingle(body).Data; - var document = JsonConvert.DeserializeObject(body); - Assert.False(document.SingleData.Attributes.ContainsKey("password")); - Assert.Equal(user.UserName, document.SingleData.Attributes["userName"]); - - // db assertions - var dbUser = await _dbContext.Users.FindAsync(returnedUser.Id); - Assert.Equal(user.UserName, dbUser.UserName); - Assert.Equal(user.Password, dbUser.Password); - } - - [Fact] - public async Task Can_Update_User_Password() - { - // Arrange - var user = _userFaker.Generate(); - _dbContext.Users.Add(user); - await _dbContext.SaveChangesAsync(); - - user.Password = _userFaker.Generate().Password; - var serializer = GetSerializer(p => new { p.Password }); - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/users/{user.Id}"; - var request = new HttpRequestMessage(httpMethod, route) - { - Content = new StringContent(serializer.Serialize(user)) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _client.SendAsync(request); - - // Assert - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - // response assertions - var body = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(body); - Assert.False(document.SingleData.Attributes.ContainsKey("password")); - Assert.Equal(user.UserName, document.SingleData.Attributes["userName"]); - - // db assertions - var dbUser = _dbContext.Users.AsNoTracking().Single(u => u.Id == user.Id); - Assert.Equal(user.Password, dbUser.Password); - } - - [Fact] - public async Task Unauthorized_TodoItem() - { - // Arrange - var route = "/api/v1/todoItems/1337"; - - // Act - var response = await _client.GetAsync(route); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.Forbidden, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); - Assert.Equal("You are not allowed to update the author of todo items.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Unauthorized_Passport() - { - // Arrange - var route = "/api/v1/people/1?include=passport"; - - // Act - var response = await _client.GetAsync(route); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.Forbidden, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); - Assert.Equal("You are not allowed to include passports on individual persons.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Unauthorized_Article() - { - // Arrange - var article = _articleFaker.Generate(); - article.Caption = "Classified"; - _dbContext.Articles.Add(article); - await _dbContext.SaveChangesAsync(); - - var route = $"/api/v1/articles/{article.Id}"; - - // Act - var response = await _client.GetAsync(route); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.Forbidden, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); - Assert.Equal("You are not allowed to see this article.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Article_Is_Hidden() - { - // Arrange - var articles = _articleFaker.Generate(3); - string toBeExcluded = "This should not be included"; - articles[0].Caption = toBeExcluded; - - _dbContext.Articles.AddRange(articles); - await _dbContext.SaveChangesAsync(); - - var route = "/api/v1/articles"; - - // Act - var response = await _client.GetAsync(route); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with body: {body}"); - Assert.DoesNotContain(toBeExcluded, body); - } - - [Fact] - public async Task Article_Through_Secondary_Endpoint_Is_Hidden() - { - // Arrange - var articles = _articleFaker.Generate(3); - string toBeExcluded = "This should not be included"; - articles[0].Caption = toBeExcluded; - var author = _authorFaker.Generate(); - author.Articles = articles; - - _dbContext.AuthorDifferentDbContextName.Add(author); - await _dbContext.SaveChangesAsync(); - - var route = $"/api/v1/authors/{author.Id}/articles"; - - // Act - var response = await _client.GetAsync(route); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with body: {body}"); - Assert.DoesNotContain(toBeExcluded, body); - } - - [Fact] - public async Task Tag_Is_Hidden() - { - // Arrange - var article = _articleFaker.Generate(); - var tags = _tagFaker.Generate(2); - string toBeExcluded = "This should not be included"; - tags[0].Name = toBeExcluded; - - var articleTags = new[] - { - new ArticleTag - { - Article = article, - Tag = tags[0] - }, - new ArticleTag - { - Article = article, - Tag = tags[1] - } - }; - _dbContext.ArticleTags.AddRange(articleTags); - await _dbContext.SaveChangesAsync(); - - // Workaround for https://github.com/dotnet/efcore/issues/21026 - var options = (JsonApiOptions) _factory.Services.GetRequiredService(); - options.DisableTopPagination = false; - options.DisableChildrenPagination = true; - - var route = "/api/v1/articles?include=tags"; - - // Act - var response = await _client.GetAsync(route); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - Assert.True(HttpStatusCode.OK == response.StatusCode, $"{route} returned {response.StatusCode} status code with body: {body}"); - Assert.DoesNotContain(toBeExcluded, body); - } - ///// - ///// In the Cascade Permission Error tests, we ensure that all the relevant - ///// resources are provided in the hook definitions. In this case, - ///// re-relating the meta object to a different article would require - ///// also a check for the lockedTodo, because we're implicitly updating - ///// its foreign key. - ///// - [Fact] - public async Task Cascade_Permission_Error_Create_ToOne_Relationship() - { - // Arrange - var lockedPerson = _personFaker.Generate(); - lockedPerson.IsLocked = true; - var passport = new Passport(_dbContext); - lockedPerson.Passport = passport; - _dbContext.People.AddRange(lockedPerson); - await _dbContext.SaveChangesAsync(); - - var content = new - { - data = new - { - type = "people", - relationships = new Dictionary - { - { "passport", new - { - data = new { type = "passports", id = lockedPerson.Passport.StringId } - } - } - } - } - }; - - var httpMethod = new HttpMethod("POST"); - var route = "/api/v1/people"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.Forbidden, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); - Assert.Equal("You are not allowed to update fields or relationships of locked todo items.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Cascade_Permission_Error_Updating_ToOne_Relationship() - { - // Arrange - var person = _personFaker.Generate(); - var passport = new Passport(_dbContext) { IsLocked = true }; - person.Passport = passport; - _dbContext.People.AddRange(person); - var newPassport = new Passport(_dbContext); - _dbContext.Passports.Add(newPassport); - await _dbContext.SaveChangesAsync(); - - var content = new - { - data = new - { - type = "people", - id = person.Id, - relationships = new Dictionary - { - { "passport", new - { - data = new { type = "passports", id = newPassport.StringId } - } - } - } - } - }; - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/people/{person.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.Forbidden, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); - Assert.Equal("You are not allowed to update fields or relationships of locked persons.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Cascade_Permission_Error_Updating_ToOne_Relationship_Deletion() - { - // Arrange - var person = _personFaker.Generate(); - var passport = new Passport(_dbContext) { IsLocked = true }; - person.Passport = passport; - _dbContext.People.AddRange(person); - var newPassport = new Passport(_dbContext); - _dbContext.Passports.Add(newPassport); - await _dbContext.SaveChangesAsync(); - - var content = new - { - data = new - { - type = "people", - id = person.Id, - relationships = new Dictionary - { - { "passport", new - { - data = (object)null - } - } - } - } - }; - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/people/{person.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.Forbidden, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); - Assert.Equal("You are not allowed to update fields or relationships of locked persons.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Cascade_Permission_Error_Delete_ToOne_Relationship() - { - // Arrange - var lockedPerson = _personFaker.Generate(); - lockedPerson.IsLocked = true; - var passport = new Passport(_dbContext); - lockedPerson.Passport = passport; - _dbContext.People.AddRange(lockedPerson); - await _dbContext.SaveChangesAsync(); - - var httpMethod = new HttpMethod("DELETE"); - var route = $"/api/v1/passports/{lockedPerson.Passport.StringId}"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.Forbidden, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); - Assert.Equal("You are not allowed to update fields or relationships of locked todo items.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Cascade_Permission_Error_Create_ToMany_Relationship() - { - // Arrange - var persons = _personFaker.Generate(2); - var lockedTodo = _todoItemFaker.Generate(); - lockedTodo.IsLocked = true; - lockedTodo.StakeHolders = persons.ToHashSet(); - _dbContext.TodoItems.Add(lockedTodo); - await _dbContext.SaveChangesAsync(); - - var content = new - { - data = new - { - type = "todoItems", - relationships = new Dictionary - { - { "stakeHolders", new - { - data = new[] - { - new { type = "people", id = persons[0].StringId }, - new { type = "people", id = persons[1].StringId } - } - - } - } - } - } - }; - - var httpMethod = new HttpMethod("POST"); - var route = "/api/v1/todoItems"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.Forbidden, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); - Assert.Equal("You are not allowed to update fields or relationships of locked todo items.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Cascade_Permission_Error_Updating_ToMany_Relationship() - { - // Arrange - var persons = _personFaker.Generate(2); - var lockedTodo = _todoItemFaker.Generate(); - lockedTodo.IsLocked = true; - lockedTodo.StakeHolders = persons.ToHashSet(); - _dbContext.TodoItems.Add(lockedTodo); - var unlockedTodo = _todoItemFaker.Generate(); - _dbContext.TodoItems.Add(unlockedTodo); - await _dbContext.SaveChangesAsync(); - - var content = new - { - data = new - { - type = "todoItems", - id = unlockedTodo.Id, - relationships = new Dictionary - { - { "stakeHolders", new - { - data = new[] - { - new { type = "people", id = persons[0].StringId }, - new { type = "people", id = persons[1].StringId } - } - - } - } - } - } - }; - - var httpMethod = new HttpMethod("PATCH"); - var route = $"/api/v1/todoItems/{unlockedTodo.Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - string serializedContent = JsonConvert.SerializeObject(content); - request.Content = new StringContent(serializedContent); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - // Act - var response = await _client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.Forbidden, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); - Assert.Equal("You are not allowed to update fields or relationships of locked todo items.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - - [Fact] - public async Task Cascade_Permission_Error_Delete_ToMany_Relationship() - { - // Arrange - var persons = _personFaker.Generate(2); - var lockedTodo = _todoItemFaker.Generate(); - lockedTodo.IsLocked = true; - lockedTodo.StakeHolders = persons.ToHashSet(); - _dbContext.TodoItems.Add(lockedTodo); - await _dbContext.SaveChangesAsync(); - - var httpMethod = new HttpMethod("DELETE"); - var route = $"/api/v1/people/{persons[0].Id}"; - var request = new HttpRequestMessage(httpMethod, route); - - // Act - var response = await _client.SendAsync(request); - - // Assert - var body = await response.Content.ReadAsStringAsync(); - AssertEqualStatusCode(HttpStatusCode.Forbidden, response); - - var errorDocument = JsonConvert.DeserializeObject(body); - Assert.Single(errorDocument.Errors); - Assert.Equal(HttpStatusCode.Forbidden, errorDocument.Errors[0].StatusCode); - Assert.Equal("You are not allowed to update fields or relationships of locked todo items.", errorDocument.Errors[0].Title); - Assert.Null(errorDocument.Errors[0].Detail); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FunctionalTestCollection.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FunctionalTestCollection.cs deleted file mode 100644 index 1030ff426c..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Spec/FunctionalTestCollection.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System; -using System.Linq.Expressions; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Client.Internal; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.Helpers.Models; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.Acceptance.Spec -{ - public class FunctionalTestCollection : IClassFixture where TFactory : class, IApplicationFactory - { - public static MediaTypeHeaderValue JsonApiContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - protected readonly TFactory _factory; - protected readonly HttpClient _client; - protected readonly AppDbContext _dbContext; - protected IResponseDeserializer _deserializer; - - public FunctionalTestCollection(TFactory factory) - { - _factory = factory; - _client = _factory.CreateClient(); - _dbContext = _factory.GetRequiredService(); - _deserializer = GetDeserializer(); - ClearDbContext(); - } - - protected Task<(string, HttpResponseMessage)> Get(string route) - { - return SendRequest("GET", route); - } - - protected Task<(string, HttpResponseMessage)> Post(string route, string content) - { - return SendRequest("POST", route, content); - } - - protected Task<(string, HttpResponseMessage)> Patch(string route, string content) - { - return SendRequest("PATCH", route, content); - } - - protected Task<(string, HttpResponseMessage)> Delete(string route) - { - return SendRequest("DELETE", route); - } - - protected IRequestSerializer GetSerializer(Expression> attributes = null, Expression> relationships = null) where TResource : class, IIdentifiable - { - var serializer = GetService(); - var graph = GetService(); - serializer.AttributesToSerialize = attributes != null ? graph.GetAttributes(attributes) : null; - serializer.RelationshipsToSerialize = relationships != null ? graph.GetRelationships(relationships) : null; - return serializer; - } - - protected IResponseDeserializer GetDeserializer() - { - var options = GetService(); - var formatter = new ResourceNameFormatter(options); - var resourcesContexts = GetService().GetResourceContexts(); - var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); - foreach (var rc in resourcesContexts) - { - if (rc.ResourceType == typeof(TodoItem) || rc.ResourceType == typeof(TodoItemCollection)) - { - continue; - } - builder.Add(rc.ResourceType, rc.IdentityType, rc.PublicName); - } - builder.Add(formatter.FormatResourceName(typeof(TodoItem))); - builder.Add(formatter.FormatResourceName(typeof(TodoItemCollection))); - return new ResponseDeserializer(builder.Build(), new ResourceFactory(_factory.ServiceProvider)); - } - - protected AppDbContext GetDbContext() => GetService(); - - protected T GetService() => _factory.GetRequiredService(); - - protected void AssertEqualStatusCode(HttpStatusCode expected, HttpResponseMessage response) - { - var responseBody = response.Content.ReadAsStringAsync().Result; - Assert.True(expected == response.StatusCode, $"Got {response.StatusCode} status code instead of {expected}. Response body: {responseBody}"); - } - - protected void ClearDbContext() - { - _dbContext.ClearTable(); - _dbContext.ClearTable(); - _dbContext.ClearTable(); - _dbContext.ClearTable(); - _dbContext.SaveChanges(); - } - - private async Task<(string, HttpResponseMessage)> SendRequest(string method, string route, string content = null) - { - var request = new HttpRequestMessage(new HttpMethod(method), route); - if (content != null) - { - request.Content = new StringContent(content); - request.Content.Headers.ContentType = JsonApiContentType; - } - var response = await _client.SendAsync(request); - var body = await response.Content?.ReadAsStringAsync(); - return (body, response); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Factories/CustomApplicationFactoryBase.cs b/test/JsonApiDotNetCoreExampleTests/Factories/CustomApplicationFactoryBase.cs deleted file mode 100644 index 04e237acb8..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Factories/CustomApplicationFactoryBase.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using System.Net.Http; -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.DependencyInjection; - -namespace JsonApiDotNetCoreExampleTests -{ - public class CustomApplicationFactoryBase : WebApplicationFactory, IApplicationFactory - { - public readonly HttpClient Client; - private readonly IServiceScope _scope; - - public IServiceProvider ServiceProvider => _scope.ServiceProvider; - - public CustomApplicationFactoryBase() - { - Client = CreateClient(); - _scope = Services.CreateScope(); - } - - public T GetRequiredService() => (T)_scope.ServiceProvider.GetRequiredService(typeof(T)); - - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.UseStartup(); - } - } - - public interface IApplicationFactory - { - IServiceProvider ServiceProvider { get; } - - T GetRequiredService(); - HttpClient CreateClient(); - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Factories/ResourceHooksApplicationFactory.cs b/test/JsonApiDotNetCoreExampleTests/Factories/ResourceHooksApplicationFactory.cs deleted file mode 100644 index 72f879054b..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Factories/ResourceHooksApplicationFactory.cs +++ /dev/null @@ -1,33 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; - -namespace JsonApiDotNetCoreExampleTests -{ - public class ResourceHooksApplicationFactory : CustomApplicationFactoryBase - { - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - base.ConfigureWebHost(builder); - - builder.ConfigureServices(services => - { - services.AddClientSerialization(); - }); - - builder.ConfigureTestServices(services => - { - services.AddJsonApi(options => - { - options.Namespace = "api/v1"; - options.DefaultPageSize = new PageSize(5); - options.IncludeTotalResourceCount = true; - options.EnableResourceHooks = true; - options.LoadDatabaseValues = true; - options.IncludeExceptionStackTraceInErrors = true; - }, - discovery => discovery.AddAssembly(typeof(JsonApiDotNetCoreExample.Program).Assembly)); - }); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Factories/StandardApplicationFactory.cs b/test/JsonApiDotNetCoreExampleTests/Factories/StandardApplicationFactory.cs deleted file mode 100644 index 8c45e2e5e7..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Factories/StandardApplicationFactory.cs +++ /dev/null @@ -1,19 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.TestHost; - -namespace JsonApiDotNetCoreExampleTests -{ - public class StandardApplicationFactory : CustomApplicationFactoryBase - { - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - base.ConfigureWebHost(builder); - - builder.ConfigureTestServices(services => - { - services.AddClientSerialization(); - }); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/Helpers/Models/TodoItemClient.cs b/test/JsonApiDotNetCoreExampleTests/Helpers/Models/TodoItemClient.cs deleted file mode 100644 index 3cbe45501c..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/Helpers/Models/TodoItemClient.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Collections.Generic; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCoreExample.Models; - -namespace JsonApiDotNetCoreExampleTests.Helpers.Models -{ - /// - /// this "client" version of the is required because the - /// base property that is overridden here does not have a setter. For a model - /// defined on a JSON:API client, it would not make sense to have an exposed attribute - /// without a setter. - /// - public class TodoItemClient : TodoItem - { - [Attr] - public new string CalculatedValue { get; set; } - } - - [Resource("todoCollections")] - public sealed class TodoItemCollectionClient : Identifiable - { - [Attr] - public string Name { get; set; } - public int OwnerId { get; set; } - - [HasMany] - public ISet TodoItems { get; set; } - - [HasOne] - public Person Owner { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/HookFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/HookFakers.cs new file mode 100644 index 0000000000..1ffabb7c0a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/HookFakers.cs @@ -0,0 +1,79 @@ +using System; +using Bogus; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.DependencyInjection; +using Person = JsonApiDotNetCoreExample.Models.Person; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceHooks +{ + internal sealed class HookFakers : FakerContainer + { + private readonly IServiceProvider _serviceProvider; + + private readonly Lazy> _lazyAuthorFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(author => author.FirstName, f => f.Person.FirstName) + .RuleFor(author => author.LastName, f => f.Person.LastName) + .RuleFor(author => author.DateOfBirth, f => f.Person.DateOfBirth) + .RuleFor(author => author.BusinessEmail, f => f.Person.Email)); + + private readonly Lazy> _lazyArticleFaker = new Lazy>(() => + new Faker
() + .UseSeed(GetFakerSeed()) + .RuleFor(article => article.Caption, f => f.Lorem.Word()) + .RuleFor(article => article.Url, f => f.Internet.Url())); + + private readonly Lazy> _lazyUserFaker; + + private readonly Lazy> _lazyTodoItemFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(todoItem => todoItem.Description, f => f.Random.Words()) + .RuleFor(todoItem => todoItem.Ordinal, f => f.Random.Long(1, 999999)) + .RuleFor(todoItem => todoItem.CreatedDate, f => f.Date.Past()) + .RuleFor(todoItem => todoItem.AchievedDate, f => f.Date.Past()) + .RuleFor(todoItem => todoItem.OffsetDate, f => f.Date.FutureOffset())); + + private readonly Lazy> _lazyPersonFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(person => person.FirstName, f => f.Person.FirstName) + .RuleFor(person => person.LastName, f => f.Person.LastName) + .RuleFor(person => person.Age, f => f.Random.Int(25, 50)) + .RuleFor(person => person.Gender, f => f.PickRandom()) + .RuleFor(person => person.Category, f => f.Lorem.Word())); + + private readonly Lazy> _lazyTagFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(tag => tag.Name, f => f.Lorem.Word()) + .RuleFor(tag => tag.Color, f => f.PickRandom())); + + public Faker Author => _lazyAuthorFaker.Value; + public Faker
Article => _lazyArticleFaker.Value; + public Faker User => _lazyUserFaker.Value; + public Faker TodoItem => _lazyTodoItemFaker.Value; + public Faker Person => _lazyPersonFaker.Value; + public Faker Tag => _lazyTagFaker.Value; + + public HookFakers(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + + _lazyUserFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .CustomInstantiator(f => new User(ResolveDbContext())) + .RuleFor(user => user.UserName, f => f.Person.UserName) + .RuleFor(user => user.Password, f => f.Internet.Password())); + } + + private AppDbContext ResolveDbContext() + { + using var scope = _serviceProvider.CreateScope(); + return scope.ServiceProvider.GetRequiredService(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs new file mode 100644 index 0000000000..b0b0214f05 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs @@ -0,0 +1,754 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Client.Internal; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExample.Definitions; +using JsonApiDotNetCoreExample.Models; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceHooks +{ + public sealed class ResourceHookTests + : IClassFixture, AppDbContext>> + { + private readonly IntegrationTestContext, AppDbContext> _testContext; + private readonly HookFakers _fakers; + + public ResourceHookTests(IntegrationTestContext, AppDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddScoped, ArticleHooksDefinition>(); + services.AddScoped, PassportHooksDefinition>(); + services.AddScoped, PersonHooksDefinition>(); + services.AddScoped, TagHooksDefinition>(); + services.AddScoped, TodoItemHooksDefinition>(); + }); + + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.DisableTopPagination = false; + options.DisableChildrenPagination = false; + + _fakers = new HookFakers(testContext.Factory.Services); + } + + [Fact] + public async Task Can_Create_User_With_Password() + { + // Arrange + var user = _fakers.User.Generate(); + + var serializer = GetRequestSerializer(p => new {p.Password, p.UserName}); + + var route = "/api/v1/users"; + + var request = new HttpRequestMessage(HttpMethod.Post, route) + { + Content = new StringContent(serializer.Serialize(user)) + }; + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + using var client = _testContext.Factory.CreateClient(); + + // Act + var response = await client.SendAsync(request); + + // Assert + response.Should().HaveStatusCode(HttpStatusCode.Created); + + var body = await response.Content.ReadAsStringAsync(); + var returnedUser = GetResponseDeserializer().DeserializeSingle(body).Data; + var document = JsonConvert.DeserializeObject(body); + + document.SingleData.Attributes.Should().NotContainKey("password"); + document.SingleData.Attributes["userName"].Should().Be(user.UserName); + + using var scope = _testContext.Factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var dbUser = await dbContext.Users.FindAsync(returnedUser.Id); + + dbUser.UserName.Should().Be(user.UserName); + dbUser.Password.Should().Be(user.Password); + } + + [Fact] + public async Task Can_Update_User_Password() + { + // Arrange + var user = _fakers.User.Generate(); + + using (var scope = _testContext.Factory.Services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + + dbContext.Users.Add(user); + await dbContext.SaveChangesAsync(); + } + + user.Password = _fakers.User.Generate().Password; + + var serializer = GetRequestSerializer(p => new {p.Password}); + + var route = $"/api/v1/users/{user.Id}"; + + var request = new HttpRequestMessage(HttpMethod.Patch, route) + { + Content = new StringContent(serializer.Serialize(user)) + }; + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + using var client = _testContext.Factory.CreateClient(); + + // Act + var response = await client.SendAsync(request); + + // Assert + response.Should().HaveStatusCode(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + var document = JsonConvert.DeserializeObject(body); + + document.SingleData.Attributes.Should().NotContainKey("password"); + document.SingleData.Attributes["userName"].Should().Be(user.UserName); + + using (var scope = _testContext.Factory.Services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + var dbUser = dbContext.Users.Single(u => u.Id == user.Id); + + dbUser.Password.Should().Be(user.Password); + } + } + + [Fact] + public async Task Unauthorized_TodoItem() + { + // Arrange + var route = "/api/v1/todoItems/1337"; + + using var client = _testContext.Factory.CreateClient(); + + // Act + var response = await client.GetAsync(route); + + // Assert + response.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + var body = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(body); + + errorDocument.Errors.Should().HaveCount(1); + errorDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + errorDocument.Errors[0].Title.Should().Be("You are not allowed to update the author of todo items."); + errorDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Unauthorized_Passport() + { + // Arrange + var route = "/api/v1/people/1?include=passport"; + + using var client = _testContext.Factory.CreateClient(); + + // Act + var response = await client.GetAsync(route); + + // Assert + response.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + var body = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(body); + + errorDocument.Errors.Should().HaveCount(1); + errorDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + errorDocument.Errors[0].Title.Should().Be("You are not allowed to include passports on individual persons."); + errorDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Unauthorized_Article() + { + // Arrange + var article = _fakers.Article.Generate(); + article.Caption = "Classified"; + + using (var scope = _testContext.Factory.Services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + + dbContext.Articles.Add(article); + await dbContext.SaveChangesAsync(); + } + + var route = $"/api/v1/articles/{article.Id}"; + + using var client = _testContext.Factory.CreateClient(); + + // Act + var response = await client.GetAsync(route); + + // Assert + response.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + var body = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(body); + + errorDocument.Errors.Should().HaveCount(1); + errorDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + errorDocument.Errors[0].Title.Should().Be("You are not allowed to see this article."); + errorDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Article_Is_Hidden() + { + // Arrange + var articles = _fakers.Article.Generate(3); + string toBeExcluded = "This should not be included"; + articles[0].Caption = toBeExcluded; + + using (var scope = _testContext.Factory.Services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + + dbContext.Articles.AddRange(articles); + await dbContext.SaveChangesAsync(); + } + + var route = "/api/v1/articles"; + + using var client = _testContext.Factory.CreateClient(); + + // Act + var response = await client.GetAsync(route); + + // Assert + response.Should().HaveStatusCode(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + body.Should().NotContain(toBeExcluded); + } + + [Fact] + public async Task Article_Through_Secondary_Endpoint_Is_Hidden() + { + // Arrange + var articles = _fakers.Article.Generate(3); + string toBeExcluded = "This should not be included"; + articles[0].Caption = toBeExcluded; + + var author = _fakers.Author.Generate(); + author.Articles = articles; + + using (var scope = _testContext.Factory.Services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + + dbContext.AuthorDifferentDbContextName.Add(author); + await dbContext.SaveChangesAsync(); + } + + var route = $"/api/v1/authors/{author.Id}/articles"; + + using var client = _testContext.Factory.CreateClient(); + + // Act + var response = await client.GetAsync(route); + + // Assert + response.Should().HaveStatusCode(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + body.Should().NotContain(toBeExcluded); + } + + [Fact] + public async Task Tag_Is_Hidden() + { + // Arrange + var article = _fakers.Article.Generate(); + var tags = _fakers.Tag.Generate(2); + string toBeExcluded = "This should not be included"; + tags[0].Name = toBeExcluded; + + var articleTags = new[] + { + new ArticleTag + { + Article = article, + Tag = tags[0] + }, + new ArticleTag + { + Article = article, + Tag = tags[1] + } + }; + + using (var scope = _testContext.Factory.Services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + + dbContext.ArticleTags.AddRange(articleTags); + await dbContext.SaveChangesAsync(); + } + + // Workaround for https://github.com/dotnet/efcore/issues/21026 + var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); + options.DisableTopPagination = false; + options.DisableChildrenPagination = true; + + var route = "/api/v1/articles?include=tags"; + + using var client = _testContext.Factory.CreateClient(); + + // Act + var response = await client.GetAsync(route); + + // Assert + response.Should().HaveStatusCode(HttpStatusCode.OK); + + var body = await response.Content.ReadAsStringAsync(); + body.Should().NotContain(toBeExcluded); + } + + [Fact] + public async Task Cascade_Permission_Error_Create_ToOne_Relationship() + { + // In the Cascade Permission Error tests, we ensure that all the relevant resources are provided in the hook definitions. In this case, + // re-relating the meta object to a different article would require also a check for the lockedTodo, because we're implicitly updating + // its foreign key. + + // Arrange + var lockedPerson = _fakers.Person.Generate(); + lockedPerson.IsLocked = true; + + using (var scope = _testContext.Factory.Services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var passport = new Passport(dbContext); + lockedPerson.Passport = passport; + + dbContext.People.Add(lockedPerson); + await dbContext.SaveChangesAsync(); + } + + var requestBody = new + { + data = new + { + type = "people", + relationships = new + { + passport = new + { + data = new + { + type = "passports", + id = lockedPerson.Passport.StringId + } + } + } + } + }; + + var route = "/api/v1/people"; + + var request = new HttpRequestMessage(HttpMethod.Post, route); + + string requestText = JsonConvert.SerializeObject(requestBody); + request.Content = new StringContent(requestText); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + using var client = _testContext.Factory.CreateClient(); + + // Act + var response = await client.SendAsync(request); + + // Assert + response.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + var body = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(body); + + errorDocument.Errors.Should().HaveCount(1); + errorDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + errorDocument.Errors[0].Title.Should().Be("You are not allowed to update fields or relationships of locked todo items."); + errorDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cascade_Permission_Error_Updating_ToOne_Relationship() + { + // Arrange + var person = _fakers.Person.Generate(); + Passport newPassport; + + using (var scope = _testContext.Factory.Services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var passport = new Passport(dbContext) {IsLocked = true}; + person.Passport = passport; + dbContext.People.Add(person); + newPassport = new Passport(dbContext); + dbContext.Passports.Add(newPassport); + await dbContext.SaveChangesAsync(); + } + + var requestBody = new + { + data = new + { + type = "people", + id = person.Id, + relationships = new + { + passport = new + { + data = new + { + type = "passports", + id = newPassport.StringId + } + } + } + } + }; + + var route = $"/api/v1/people/{person.Id}"; + + var request = new HttpRequestMessage(HttpMethod.Patch, route); + + string requestText = JsonConvert.SerializeObject(requestBody); + request.Content = new StringContent(requestText); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + using var client = _testContext.Factory.CreateClient(); + + // Act + var response = await client.SendAsync(request); + + // Assert + response.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + var body = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(body); + + errorDocument.Errors.Should().HaveCount(1); + errorDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + errorDocument.Errors[0].Title.Should().Be("You are not allowed to update fields or relationships of locked persons."); + errorDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cascade_Permission_Error_Updating_ToOne_Relationship_Deletion() + { + // Arrange + var person = _fakers.Person.Generate(); + + using (var scope = _testContext.Factory.Services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var passport = new Passport(dbContext) {IsLocked = true}; + person.Passport = passport; + dbContext.People.Add(person); + var newPassport = new Passport(dbContext); + dbContext.Passports.Add(newPassport); + await dbContext.SaveChangesAsync(); + } + + var requestBody = new + { + data = new + { + type = "people", + id = person.Id, + relationships = new + { + passport = new + { + data = (object) null + } + } + } + }; + + var route = $"/api/v1/people/{person.Id}"; + + var request = new HttpRequestMessage(HttpMethod.Patch, route); + + string requestText = JsonConvert.SerializeObject(requestBody); + request.Content = new StringContent(requestText); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + using var client = _testContext.Factory.CreateClient(); + + // Act + var response = await client.SendAsync(request); + + // Assert + response.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + var body = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(body); + + errorDocument.Errors.Should().HaveCount(1); + errorDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + errorDocument.Errors[0].Title.Should().Be("You are not allowed to update fields or relationships of locked persons."); + errorDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cascade_Permission_Error_Delete_ToOne_Relationship() + { + // Arrange + var lockedPerson = _fakers.Person.Generate(); + lockedPerson.IsLocked = true; + + using (var scope = _testContext.Factory.Services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var passport = new Passport(dbContext); + lockedPerson.Passport = passport; + dbContext.People.Add(lockedPerson); + await dbContext.SaveChangesAsync(); + } + + var route = $"/api/v1/passports/{lockedPerson.Passport.StringId}"; + + var request = new HttpRequestMessage(HttpMethod.Delete, route); + + using var client = _testContext.Factory.CreateClient(); + + // Act + var response = await client.SendAsync(request); + + // Assert + response.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + var body = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(body); + + errorDocument.Errors.Should().HaveCount(1); + errorDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + errorDocument.Errors[0].Title.Should().Be("You are not allowed to update fields or relationships of locked todo items."); + errorDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cascade_Permission_Error_Create_ToMany_Relationship() + { + // Arrange + var persons = _fakers.Person.Generate(2); + var lockedTodo = _fakers.TodoItem.Generate(); + lockedTodo.IsLocked = true; + lockedTodo.StakeHolders = persons.ToHashSet(); + + using (var scope = _testContext.Factory.Services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + + dbContext.TodoItems.Add(lockedTodo); + await dbContext.SaveChangesAsync(); + } + + var requestBody = new + { + data = new + { + type = "todoItems", + relationships = new + { + stakeHolders = new + { + data = new[] + { + new + { + type = "people", + id = persons[0].StringId + }, + new + { + type = "people", + id = persons[1].StringId + } + } + } + } + } + }; + + var route = "/api/v1/todoItems"; + + var request = new HttpRequestMessage(HttpMethod.Post, route); + + string requestText = JsonConvert.SerializeObject(requestBody); + request.Content = new StringContent(requestText); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + using var client = _testContext.Factory.CreateClient(); + + // Act + var response = await client.SendAsync(request); + + // Assert + response.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + var body = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(body); + + errorDocument.Errors.Should().HaveCount(1); + errorDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + errorDocument.Errors[0].Title.Should().Be("You are not allowed to update fields or relationships of locked todo items."); + errorDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cascade_Permission_Error_Updating_ToMany_Relationship() + { + // Arrange + var persons = _fakers.Person.Generate(2); + + var lockedTodo = _fakers.TodoItem.Generate(); + lockedTodo.IsLocked = true; + lockedTodo.StakeHolders = persons.ToHashSet(); + + var unlockedTodo = _fakers.TodoItem.Generate(); + + using (var scope = _testContext.Factory.Services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + + dbContext.TodoItems.AddRange(lockedTodo, unlockedTodo); + await dbContext.SaveChangesAsync(); + } + + var requestBody = new + { + data = new + { + type = "todoItems", + id = unlockedTodo.Id, + relationships = new + { + stakeHolders = new + { + data = new[] + { + new + { + type = "people", + id = persons[0].StringId + }, + new + { + type = "people", + id = persons[1].StringId + } + } + } + } + } + }; + + var route = $"/api/v1/todoItems/{unlockedTodo.Id}"; + + var request = new HttpRequestMessage(HttpMethod.Patch, route); + + string requestText = JsonConvert.SerializeObject(requestBody); + request.Content = new StringContent(requestText); + request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); + + using var client = _testContext.Factory.CreateClient(); + + // Act + var response = await client.SendAsync(request); + + // Assert + response.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + var body = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(body); + + errorDocument.Errors.Should().HaveCount(1); + errorDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + errorDocument.Errors[0].Title.Should().Be("You are not allowed to update fields or relationships of locked todo items."); + errorDocument.Errors[0].Detail.Should().BeNull(); + } + + [Fact] + public async Task Cascade_Permission_Error_Delete_ToMany_Relationship() + { + // Arrange + var persons = _fakers.Person.Generate(2); + var lockedTodo = _fakers.TodoItem.Generate(); + lockedTodo.IsLocked = true; + lockedTodo.StakeHolders = persons.ToHashSet(); + + using (var scope = _testContext.Factory.Services.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + + dbContext.TodoItems.Add(lockedTodo); + await dbContext.SaveChangesAsync(); + } + + var route = $"/api/v1/people/{persons[0].Id}"; + + var request = new HttpRequestMessage(HttpMethod.Delete, route); + + using var client = _testContext.Factory.CreateClient(); + + // Act + var response = await client.SendAsync(request); + + // Assert + response.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + var body = await response.Content.ReadAsStringAsync(); + var errorDocument = JsonConvert.DeserializeObject(body); + + errorDocument.Errors.Should().HaveCount(1); + errorDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + errorDocument.Errors[0].Title.Should().Be("You are not allowed to update fields or relationships of locked todo items."); + errorDocument.Errors[0].Detail.Should().BeNull(); + } + + private IRequestSerializer GetRequestSerializer(Expression> attributes = null, + Expression> relationships = null) + where TResource : class, IIdentifiable + { + var graph = _testContext.Factory.Services.GetRequiredService(); + + var serializer = _testContext.Factory.Services.GetRequiredService(); + serializer.AttributesToSerialize = attributes != null ? graph.GetAttributes(attributes) : null; + serializer.RelationshipsToSerialize = relationships != null ? graph.GetRelationships(relationships) : null; + return serializer; + } + + private IResponseDeserializer GetResponseDeserializer() + { + return _testContext.Factory.Services.GetRequiredService(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHooksStartup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHooksStartup.cs new file mode 100644 index 0000000000..fdcfa09376 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHooksStartup.cs @@ -0,0 +1,36 @@ +using JsonApiDotNetCore.Configuration; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceHooks +{ + public sealed class ResourceHooksStartup : TestableStartup + where TDbContext : DbContext + { + public ResourceHooksStartup(IConfiguration configuration) + : base(configuration) + { + } + + public override void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + + base.ConfigureServices(services); + + services.AddControllersFromTestProject(); + services.AddClientSerialization(); + } + + protected override void SetJsonApiOptions(JsonApiOptions options) + { + base.SetJsonApiOptions(options); + + options.Namespace = "api/v1"; + options.EnableResourceHooks = true; + options.LoadDatabaseValues = true; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ServiceCollectionExtensions.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..f0b214af83 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCoreExample; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests +{ + public static class ServiceCollectionExtensions + { + public static void AddControllersFromTestProject(this IServiceCollection services) + { + var part = new AssemblyPart(typeof(EmptyStartup).Assembly); + services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); + } + } +} From 255ed36df6e9f4ab1af0f9962b410785f6b8389d Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 3 Feb 2021 01:14:32 +0100 Subject: [PATCH 090/123] General cleanup --- .../Controllers/PassportsController.cs | 2 +- .../Controllers/TagsController.cs | 2 +- .../Data/AppDbContext.cs | 3 -- .../Startups/Startup.cs | 24 ++++++---------- .../AppDbContextExtensions.cs | 22 --------------- .../HookFakers.cs => ExampleFakers.cs} | 7 +++-- .../{IntegrationTests => }/FakerContainer.cs | 2 +- .../PaginationWithTotalCountTests.cs | 26 ++++++++--------- .../PaginationWithoutTotalCountTests.cs | 12 ++++---- .../Pagination/RangeValidationTests.cs | 7 +++-- .../ResourceHooks/ResourceHookTests.cs | 4 +-- .../BlockingHttpDeleteController.cs | 2 +- .../BlockingHttpPatchController.cs | 2 +- .../BlockingHttpPostController.cs | 2 +- .../BlockingWritesController.cs | 2 +- .../IntegrationTests/Sorting/SortTests.cs | 15 +++------- .../SparseFieldSets/SparseFieldSetTests.cs | 28 ++++--------------- .../ObjectAssertionsExtensions.cs | 2 +- .../ServiceCollectionExtensions.cs | 2 +- .../{IntegrationTests => }/TestableStartup.cs | 2 +- .../BaseJsonApiController_Tests.cs | 2 +- 21 files changed, 58 insertions(+), 112 deletions(-) rename test/JsonApiDotNetCoreExampleTests/{IntegrationTests/ResourceHooks/HookFakers.cs => ExampleFakers.cs} (94%) rename test/JsonApiDotNetCoreExampleTests/{IntegrationTests => }/FakerContainer.cs (97%) rename test/JsonApiDotNetCoreExampleTests/{IntegrationTests => }/ObjectAssertionsExtensions.cs (97%) rename test/JsonApiDotNetCoreExampleTests/{IntegrationTests => }/ServiceCollectionExtensions.cs (89%) rename test/JsonApiDotNetCoreExampleTests/{IntegrationTests => }/TestableStartup.cs (95%) diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs index 62fa1e96c3..0a737184b7 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/PassportsController.cs @@ -8,7 +8,7 @@ namespace JsonApiDotNetCoreExample.Controllers { public sealed class PassportsController : JsonApiController { - public PassportsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + public PassportsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) : base(options, loggerFactory, resourceService) { } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs index c06452cef7..154892df7a 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs @@ -11,7 +11,7 @@ public sealed class TagsController : JsonApiController public TagsController( IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) + IResourceService resourceService) : base(options, loggerFactory, resourceService) { } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index 41e21feecc..6107186144 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -12,13 +12,10 @@ public sealed class AppDbContext : DbContext public DbSet TodoItems { get; set; } public DbSet Passports { get; set; } public DbSet People { get; set; } - public DbSet TodoItemCollections { get; set; } public DbSet
Articles { get; set; } public DbSet AuthorDifferentDbContextName { get; set; } public DbSet Users { get; set; } - public DbSet PersonRoles { get; set; } public DbSet ArticleTags { get; set; } - public DbSet Tags { get; set; } public DbSet Blogs { get; set; } public AppDbContext(DbContextOptions options, ISystemClock systemClock) : base(options) diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs index 983f6a9dad..553ad27999 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs @@ -14,9 +14,11 @@ namespace JsonApiDotNetCoreExample { public class Startup : EmptyStartup { + private static readonly Version _postgresCiBuildVersion = new Version(9, 6); private readonly string _connectionString; - public Startup(IConfiguration configuration) : base(configuration) + public Startup(IConfiguration configuration) + : base(configuration) { string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; _connectionString = configuration["Data:DefaultConnection"].Replace("###", postgresPassword); @@ -24,28 +26,18 @@ public Startup(IConfiguration configuration) : base(configuration) public override void ConfigureServices(IServiceCollection services) { - ConfigureClock(services); + services.AddSingleton(); services.AddDbContext(options => { options.EnableSensitiveDataLogging(); - options.UseNpgsql(_connectionString, innerOptions => innerOptions.SetPostgresVersion(new Version(9, 6))); - }, - // TODO: Remove ServiceLifetime.Transient, after all integration tests have been converted to use IntegrationTestContext. - ServiceLifetime.Transient); + options.UseNpgsql(_connectionString, postgresOptions => postgresOptions.SetPostgresVersion(_postgresCiBuildVersion)); + }); services.AddJsonApi(ConfigureJsonApiOptions, discovery => discovery.AddCurrentAssembly()); - - // once all tests have been moved to WebApplicationFactory format we can get rid of this line below - services.AddClientSerialization(); } - protected virtual void ConfigureClock(IServiceCollection services) - { - services.AddSingleton(); - } - - protected virtual void ConfigureJsonApiOptions(JsonApiOptions options) + protected void ConfigureJsonApiOptions(JsonApiOptions options) { options.IncludeExceptionStackTraceInErrors = true; options.Namespace = "api/v1"; @@ -63,7 +55,7 @@ public override void Configure(IApplicationBuilder app, IWebHostEnvironment envi var appDbContext = scope.ServiceProvider.GetRequiredService(); appDbContext.Database.EnsureCreated(); } - + app.UseRouting(); app.UseJsonApi(); app.UseEndpoints(endpoints => endpoints.MapControllers()); diff --git a/test/JsonApiDotNetCoreExampleTests/AppDbContextExtensions.cs b/test/JsonApiDotNetCoreExampleTests/AppDbContextExtensions.cs index a96642ad14..430cd60968 100644 --- a/test/JsonApiDotNetCoreExampleTests/AppDbContextExtensions.cs +++ b/test/JsonApiDotNetCoreExampleTests/AppDbContextExtensions.cs @@ -57,27 +57,5 @@ private static async Task ClearTablesAsync(this DbContext dbContext, params Type } } } - - public static void ClearTable(this DbContext dbContext) where TEntity : class - { - var entityType = dbContext.Model.FindEntityType(typeof(TEntity)); - if (entityType == null) - { - throw new InvalidOperationException($"Table for '{typeof(TEntity).Name}' not found."); - } - - string tableName = entityType.GetTableName(); - - // PERF: We first try to clear the table, which is fast and usually succeeds, unless foreign key constraints are violated. - // In that case, we recursively delete all related data, which is slow. - try - { - dbContext.Database.ExecuteSqlRaw("delete from \"" + tableName + "\""); - } - catch (PostgresException) - { - dbContext.Database.ExecuteSqlRaw("truncate table \"" + tableName + "\" cascade"); - } - } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/HookFakers.cs b/test/JsonApiDotNetCoreExampleTests/ExampleFakers.cs similarity index 94% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/HookFakers.cs rename to test/JsonApiDotNetCoreExampleTests/ExampleFakers.cs index 1ffabb7c0a..28ed67a335 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/HookFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/ExampleFakers.cs @@ -2,12 +2,13 @@ using Bogus; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using JsonApiDotNetCoreExampleTests.IntegrationTests; using Microsoft.Extensions.DependencyInjection; using Person = JsonApiDotNetCoreExample.Models.Person; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceHooks +namespace JsonApiDotNetCoreExampleTests { - internal sealed class HookFakers : FakerContainer + internal sealed class ExampleFakers : FakerContainer { private readonly IServiceProvider _serviceProvider; @@ -58,7 +59,7 @@ internal sealed class HookFakers : FakerContainer public Faker Person => _lazyPersonFaker.Value; public Faker Tag => _lazyTagFaker.Value; - public HookFakers(IServiceProvider serviceProvider) + public ExampleFakers(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/FakerContainer.cs b/test/JsonApiDotNetCoreExampleTests/FakerContainer.cs similarity index 97% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/FakerContainer.cs rename to test/JsonApiDotNetCoreExampleTests/FakerContainer.cs index 50ce77c416..be07fe5851 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/FakerContainer.cs +++ b/test/JsonApiDotNetCoreExampleTests/FakerContainer.cs @@ -4,7 +4,7 @@ using System.Reflection; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests +namespace JsonApiDotNetCoreExampleTests { internal abstract class FakerContainer { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs index 00801c4690..d1ea71a2ea 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs @@ -2,7 +2,6 @@ using System.Linq; using System.Net; using System.Threading.Tasks; -using Bogus; using FluentAssertions; using FluentAssertions.Extensions; using JsonApiDotNetCore.Configuration; @@ -12,7 +11,6 @@ using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.DependencyInjection; using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Pagination { @@ -21,7 +19,7 @@ public sealed class PaginationWithTotalCountTests : IClassFixture _testContext; - private readonly Faker _todoItemFaker = new Faker(); + private readonly ExampleFakers _fakers; public PaginationWithTotalCountTests(IntegrationTestContext testContext) { @@ -36,6 +34,8 @@ public PaginationWithTotalCountTests(IntegrationTestContext // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - Assert.Equal("http://localhost" + route, responseDocument.Links.Self); + responseDocument.Links.Self.Should().Be("http://localhost" + route); if (firstLink != null) { var expected = "http://localhost" + SetPageNumberInUrl(routePrefix, firstLink.Value); - Assert.Equal(expected, responseDocument.Links.First); + responseDocument.Links.First.Should().Be(expected); } else { - Assert.Null(responseDocument.Links.First); + responseDocument.Links.First.Should().BeNull(); } if (prevLink != null) { var expected = "http://localhost" + SetPageNumberInUrl(routePrefix, prevLink.Value); - Assert.Equal(expected, responseDocument.Links.Prev); + responseDocument.Links.Prev.Should().Be(expected); } else { - Assert.Null(responseDocument.Links.Prev); + responseDocument.Links.Prev.Should().BeNull(); } if (nextLink != null) { var expected = "http://localhost" + SetPageNumberInUrl(routePrefix, nextLink.Value); - Assert.Equal(expected, responseDocument.Links.Next); + responseDocument.Links.Next.Should().Be(expected); } else { - Assert.Null(responseDocument.Links.Next); + responseDocument.Links.Next.Should().BeNull(); } if (lastLink != null) { var expected = "http://localhost" + SetPageNumberInUrl(routePrefix, lastLink.Value); - Assert.Equal(expected, responseDocument.Links.Last); + responseDocument.Links.Last.Should().Be(expected); } else { - Assert.Null(responseDocument.Links.Last); + responseDocument.Links.Last.Should().BeNull(); } static string SetPageNumberInUrl(string url, int pageNumber) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithoutTotalCountTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithoutTotalCountTests.cs index 82c87d0429..2b9d71850d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithoutTotalCountTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithoutTotalCountTests.cs @@ -1,6 +1,5 @@ using System.Net; using System.Threading.Tasks; -using Bogus; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; @@ -17,8 +16,7 @@ public sealed class PaginationWithoutTotalCountTests : IClassFixture _testContext; - - private readonly Faker
_articleFaker = new Faker
(); + private readonly ExampleFakers _fakers; public PaginationWithoutTotalCountTests(IntegrationTestContext testContext) { @@ -29,6 +27,8 @@ public PaginationWithoutTotalCountTests(IntegrationTestContext public async Task When_page_number_is_specified_in_query_string_with_partially_filled_page_it_should_render_pagination_links() { // Arrange - var articles = _articleFaker.Generate(12); + var articles = _fakers.Article.Generate(12); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -145,7 +145,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task When_page_number_is_specified_in_query_string_with_full_page_it_should_render_pagination_links() { // Arrange - var articles = _articleFaker.Generate(_defaultPageSize * 3); + var articles = _fakers.Article.Generate(_defaultPageSize * 3); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -179,7 +179,7 @@ public async Task When_page_number_is_specified_in_query_string_with_full_page_o // Arrange var author = new Author { - Articles = _articleFaker.Generate(_defaultPageSize * 3) + Articles = _fakers.Article.Generate(_defaultPageSize * 3) }; await _testContext.RunOnDatabaseAsync(async dbContext => diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationTests.cs index 387eb8f99a..de65c323e2 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationTests.cs @@ -1,6 +1,5 @@ using System.Net; using System.Threading.Tasks; -using Bogus; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; @@ -15,7 +14,7 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Pagination public sealed class RangeValidationTests : IClassFixture> { private readonly IntegrationTestContext _testContext; - private readonly Faker _todoItemFaker = new Faker(); + private readonly ExampleFakers _fakers; private const int _defaultPageSize = 5; @@ -27,6 +26,8 @@ public RangeValidationTests(IntegrationTestContext testCo options.DefaultPageSize = new PageSize(_defaultPageSize); options.MaximumPageSize = null; options.MaximumPageNumber = null; + + _fakers = new ExampleFakers(testContext.Factory.Services); } [Fact] @@ -84,7 +85,7 @@ public async Task When_page_number_is_positive_it_must_succeed() public async Task When_page_number_is_too_high_it_must_return_empty_set_of_resources() { // Arrange - var todoItems = _todoItemFaker.Generate(3); + var todoItems = _fakers.TodoItem.Generate(3); await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs index b0b0214f05..e798e1a3c6 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs @@ -24,7 +24,7 @@ public sealed class ResourceHookTests : IClassFixture, AppDbContext>> { private readonly IntegrationTestContext, AppDbContext> _testContext; - private readonly HookFakers _fakers; + private readonly ExampleFakers _fakers; public ResourceHookTests(IntegrationTestContext, AppDbContext> testContext) { @@ -43,7 +43,7 @@ public ResourceHookTests(IntegrationTestContext { public BlockingHttpDeleteController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) + IResourceService resourceService) : base(options, loggerFactory, resourceService) { } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingHttpPatchController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingHttpPatchController.cs index 9edfdb07c5..4e9349be06 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingHttpPatchController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingHttpPatchController.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers public sealed class BlockingHttpPatchController : JsonApiController { public BlockingHttpPatchController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) + IResourceService resourceService) : base(options, loggerFactory, resourceService) { } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingHttpPostController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingHttpPostController.cs index 2dbaaadf80..18a0589559 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingHttpPostController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingHttpPostController.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers public sealed class BlockingHttpPostController : JsonApiController
{ public BlockingHttpPostController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) + IResourceService
resourceService) : base(options, loggerFactory, resourceService) { } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingWritesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingWritesController.cs index 21602e5487..a78da3511e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingWritesController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/BlockingWritesController.cs @@ -11,7 +11,7 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers public sealed class BlockingWritesController : JsonApiController { public BlockingWritesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) + IResourceService resourceService) : base(options, loggerFactory, resourceService) { } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs index d37ff005f4..950e0a752c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs @@ -2,7 +2,6 @@ using System.Linq; using System.Net; using System.Threading.Tasks; -using Bogus; using FluentAssertions; using FluentAssertions.Extensions; using JsonApiDotNetCore.Serialization.Objects; @@ -10,25 +9,19 @@ using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Xunit; -using Person = JsonApiDotNetCoreExample.Models.Person; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Sorting { public sealed class SortTests : IClassFixture> { private readonly IntegrationTestContext _testContext; - private readonly Faker
_articleFaker; - private readonly Faker _authorFaker; + private readonly ExampleFakers _fakers; public SortTests(IntegrationTestContext testContext) { _testContext = testContext; - _articleFaker = new Faker
() - .RuleFor(a => a.Caption, f => f.Random.AlphaNumeric(10)); - - _authorFaker = new Faker() - .RuleFor(a => a.LastName, f => f.Random.Words(2)); + _fakers = new ExampleFakers(testContext.Factory.Services); } [Fact] @@ -282,7 +275,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_sort_in_scope_of_HasMany_relationship() { // Arrange - var author = _authorFaker.Generate(); + var author = _fakers.Author.Generate(); author.Articles = new List
{ new Article {Caption = "B"}, @@ -360,7 +353,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_sort_in_scope_of_HasManyThrough_relationship() { // Arrange - var article = _articleFaker.Generate(); + var article = _fakers.Article.Generate(); article.ArticleTags = new HashSet { new ArticleTag diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs index 029ffe87ac..445788b4f1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs @@ -2,7 +2,6 @@ using System.Linq; using System.Net; using System.Threading.Tasks; -using Bogus; using FluentAssertions; using FluentAssertions.Extensions; using JsonApiDotNetCore.Configuration; @@ -11,8 +10,6 @@ using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Authentication; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -21,9 +18,7 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SparseFieldSets public sealed class SparseFieldSetTests : IClassFixture> { private readonly IntegrationTestContext _testContext; - private readonly Faker
_articleFaker; - private readonly Faker _authorFaker; - private readonly Faker _userFaker; + private readonly ExampleFakers _fakers; public SparseFieldSetTests(IntegrationTestContext testContext) { @@ -41,18 +36,7 @@ public SparseFieldSetTests(IntegrationTestContext testCon services.AddScoped, JsonApiResourceService
>(); }); - _articleFaker = new Faker
() - .RuleFor(a => a.Caption, f => f.Random.AlphaNumeric(10)); - - _authorFaker = new Faker() - .RuleFor(a => a.LastName, f => f.Random.Words(2)); - - var systemClock = testContext.Factory.Services.GetRequiredService(); - var options = testContext.Factory.Services.GetRequiredService>(); - var tempDbContext = new AppDbContext(options, systemClock); - - _userFaker = new Faker() - .CustomInstantiator(f => new User(tempDbContext)); + _fakers = new ExampleFakers(testContext.Factory.Services); } [Fact] @@ -282,7 +266,7 @@ public async Task Can_select_fields_of_HasOne_relationship() var store = _testContext.Factory.Services.GetRequiredService(); store.Clear(); - var article = _articleFaker.Generate(); + var article = _fakers.Article.Generate(); article.Caption = "Some"; article.Author = new Author { @@ -338,7 +322,7 @@ public async Task Can_select_fields_of_HasMany_relationship() var store = _testContext.Factory.Services.GetRequiredService(); store.Clear(); - var author = _authorFaker.Generate(); + var author = _fakers.Author.Generate(); author.LastName = "Smith"; author.Articles = new List
{ @@ -460,7 +444,7 @@ public async Task Can_select_fields_of_HasManyThrough_relationship() var store = _testContext.Factory.Services.GetRequiredService(); store.Clear(); - var article = _articleFaker.Generate(); + var article = _fakers.Article.Generate(); article.Caption = "Some"; article.ArticleTags = new HashSet { @@ -724,7 +708,7 @@ public async Task Cannot_select_on_unknown_resource_type() public async Task Cannot_select_attribute_with_blocked_capability() { // Arrange - var user = _userFaker.Generate(); + var user = _fakers.User.Generate(); var route = $"/api/v1/users/{user.Id}?fields[users]=password"; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs b/test/JsonApiDotNetCoreExampleTests/ObjectAssertionsExtensions.cs similarity index 97% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs rename to test/JsonApiDotNetCoreExampleTests/ObjectAssertionsExtensions.cs index e5a6c4e1e1..db75f5edd8 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ObjectAssertionsExtensions.cs +++ b/test/JsonApiDotNetCoreExampleTests/ObjectAssertionsExtensions.cs @@ -4,7 +4,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests +namespace JsonApiDotNetCoreExampleTests { public static class ObjectAssertionsExtensions { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ServiceCollectionExtensions.cs b/test/JsonApiDotNetCoreExampleTests/ServiceCollectionExtensions.cs similarity index 89% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ServiceCollectionExtensions.cs rename to test/JsonApiDotNetCoreExampleTests/ServiceCollectionExtensions.cs index f0b214af83..93321b9897 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ServiceCollectionExtensions.cs +++ b/test/JsonApiDotNetCoreExampleTests/ServiceCollectionExtensions.cs @@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Mvc.ApplicationParts; using Microsoft.Extensions.DependencyInjection; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests +namespace JsonApiDotNetCoreExampleTests { public static class ServiceCollectionExtensions { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/TestableStartup.cs b/test/JsonApiDotNetCoreExampleTests/TestableStartup.cs similarity index 95% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/TestableStartup.cs rename to test/JsonApiDotNetCoreExampleTests/TestableStartup.cs index e69ed360b8..aaba661a65 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/TestableStartup.cs +++ b/test/JsonApiDotNetCoreExampleTests/TestableStartup.cs @@ -8,7 +8,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Converters; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests +namespace JsonApiDotNetCoreExampleTests { public class TestableStartup : EmptyStartup where TDbContext : DbContext diff --git a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs index b11004d236..a2a63c638a 100644 --- a/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs +++ b/test/UnitTests/Controllers/BaseJsonApiController_Tests.cs @@ -29,7 +29,7 @@ public sealed class ResourceController : BaseJsonApiController public ResourceController( IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) + IResourceService resourceService) : base(options, loggerFactory, resourceService) { } From c7000b803df9af168a4ed640bae5bd8d43b079a5 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 4 Feb 2021 14:32:07 +0100 Subject: [PATCH 091/123] Extracted test building blocks in shared project --- JsonApiDotNetCore.sln | 15 ++ test/DiscoveryTests/DiscoveryTests.csproj | 2 +- .../ExampleFakers.cs | 1 - .../IntegrationTestContext.cs | 230 +--------------- .../JsonApiDotNetCoreExampleTests.csproj | 4 +- .../AbsoluteLinksInApiNamespaceStartup.cs | 1 - .../AbsoluteLinksNoNamespaceStartup.cs | 1 - .../Startups/ModelStateValidationStartup.cs | 1 - .../RelativeLinksInApiNamespaceStartup.cs | 1 - .../RelativeLinksNoNamespaceStartup.cs | 1 - .../{ => Startups}/TestableStartup.cs | 0 .../MultiDbContextTests.csproj | 3 +- .../NoEntityFrameworkTests.csproj | 2 +- .../AppDbContextExtensions.cs | 0 .../BaseIntegrationTestContext.cs | 248 ++++++++++++++++++ .../FakeLoggerFactory.cs | 14 +- .../FakerContainer.cs | 2 +- .../FrozenSystemClock.cs | 2 +- .../HttpResponseMessageExtensions.cs | 0 .../IntegrationTestConfiguration.cs | 2 +- .../NeverSameResourceChangeTracker.cs | 2 +- .../ObjectAssertionsExtensions.cs | 0 .../TestBuildingBlocks.csproj | 19 ++ .../EntityFrameworkCoreRepositoryTests.cs | 1 + .../IServiceCollectionExtensionsTests.cs | 1 + test/UnitTests/FakeLoggerFactory.cs | 41 --- test/UnitTests/FrozenSystemClock.cs | 20 -- .../Internal/ResourceGraphBuilderTests.cs | 6 +- .../ResourceConstructionExpressionTests.cs | 1 + .../Models/ResourceConstructionTests.cs | 1 + .../ResourceHooks/ResourceHooksTestsSetup.cs | 1 + test/UnitTests/UnitTests.csproj | 4 +- 32 files changed, 308 insertions(+), 319 deletions(-) rename test/JsonApiDotNetCoreExampleTests/{ => Startups}/TestableStartup.cs (100%) rename test/{JsonApiDotNetCoreExampleTests => TestBuildingBlocks}/AppDbContextExtensions.cs (100%) create mode 100644 test/TestBuildingBlocks/BaseIntegrationTestContext.cs rename test/{JsonApiDotNetCoreExampleTests => TestBuildingBlocks}/FakeLoggerFactory.cs (71%) rename test/{JsonApiDotNetCoreExampleTests => TestBuildingBlocks}/FakerContainer.cs (98%) rename test/{JsonApiDotNetCoreExampleTests => TestBuildingBlocks}/FrozenSystemClock.cs (84%) rename test/{JsonApiDotNetCoreExampleTests => TestBuildingBlocks}/HttpResponseMessageExtensions.cs (100%) rename test/{JsonApiDotNetCoreExampleTests => TestBuildingBlocks}/IntegrationTestConfiguration.cs (94%) rename test/{JsonApiDotNetCoreExampleTests => TestBuildingBlocks}/NeverSameResourceChangeTracker.cs (85%) rename test/{JsonApiDotNetCoreExampleTests => TestBuildingBlocks}/ObjectAssertionsExtensions.cs (100%) create mode 100644 test/TestBuildingBlocks/TestBuildingBlocks.csproj delete mode 100644 test/UnitTests/FakeLoggerFactory.cs delete mode 100644 test/UnitTests/FrozenSystemClock.cs diff --git a/JsonApiDotNetCore.sln b/JsonApiDotNetCore.sln index 0fc8aa3995..8c20805c1f 100644 --- a/JsonApiDotNetCore.sln +++ b/JsonApiDotNetCore.sln @@ -45,6 +45,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MultiDbContextExample", "sr EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MultiDbContextTests", "test\MultiDbContextTests\MultiDbContextTests.csproj", "{EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestBuildingBlocks", "test\TestBuildingBlocks\TestBuildingBlocks.csproj", "{210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -199,6 +201,18 @@ Global {EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA}.Release|x64.Build.0 = Release|Any CPU {EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA}.Release|x86.ActiveCfg = Release|Any CPU {EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA}.Release|x86.Build.0 = Release|Any CPU + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Debug|Any CPU.Build.0 = Debug|Any CPU + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Debug|x64.ActiveCfg = Debug|Any CPU + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Debug|x64.Build.0 = Debug|Any CPU + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Debug|x86.ActiveCfg = Debug|Any CPU + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Debug|x86.Build.0 = Debug|Any CPU + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Release|Any CPU.ActiveCfg = Release|Any CPU + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Release|Any CPU.Build.0 = Release|Any CPU + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Release|x64.ActiveCfg = Release|Any CPU + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Release|x64.Build.0 = Release|Any CPU + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Release|x86.ActiveCfg = Release|Any CPU + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -216,6 +230,7 @@ Global {21D27239-138D-4604-8E49-DCBE41BCE4C8} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF} {6CAFDDBE-00AB-4784-801B-AB419C3C3A26} = {026FBC6C-AF76-4568-9B87-EC73457899FD} {EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} + {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A2421882-8F0A-4905-928F-B550B192F9A4} diff --git a/test/DiscoveryTests/DiscoveryTests.csproj b/test/DiscoveryTests/DiscoveryTests.csproj index 94d67936e3..93223b9c41 100644 --- a/test/DiscoveryTests/DiscoveryTests.csproj +++ b/test/DiscoveryTests/DiscoveryTests.csproj @@ -11,11 +11,11 @@ + - \ No newline at end of file diff --git a/test/JsonApiDotNetCoreExampleTests/ExampleFakers.cs b/test/JsonApiDotNetCoreExampleTests/ExampleFakers.cs index 28ed67a335..eb33e1a141 100644 --- a/test/JsonApiDotNetCoreExampleTests/ExampleFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/ExampleFakers.cs @@ -2,7 +2,6 @@ using Bogus; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests.IntegrationTests; using Microsoft.Extensions.DependencyInjection; using Person = JsonApiDotNetCoreExample.Models.Person; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs index ea0f41f279..0632e170ae 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs @@ -1,17 +1,5 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using JsonApiDotNetCore.Middleware; using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; namespace JsonApiDotNetCoreExampleTests { @@ -23,225 +11,9 @@ namespace JsonApiDotNetCoreExampleTests /// /// The server Startup class, which can be defined in the test project. /// The EF Core database context, which can be defined in the test project. - public class IntegrationTestContext : IDisposable + public class IntegrationTestContext : BaseIntegrationTestContext where TStartup : class where TDbContext : DbContext { - private readonly Lazy> _lazyFactory; - private Action _loggingConfiguration; - private Action _beforeServicesConfiguration; - private Action _afterServicesConfiguration; - - public WebApplicationFactory Factory => _lazyFactory.Value; - - public IntegrationTestContext() - { - _lazyFactory = new Lazy>(CreateFactory); - } - - private WebApplicationFactory CreateFactory() - { - string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - string dbConnectionString = - $"Host=localhost;Port=5432;Database=JsonApiTest-{Guid.NewGuid():N};User ID=postgres;Password={postgresPassword}"; - - var factory = new IntegrationTestWebApplicationFactory(); - - factory.ConfigureLogging(_loggingConfiguration); - - factory.ConfigureServicesBeforeStartup(services => - { - _beforeServicesConfiguration?.Invoke(services); - - services.AddDbContext(options => - { - options.UseNpgsql(dbConnectionString, - postgresOptions => postgresOptions.SetPostgresVersion(new Version(9, 6))); - - options.EnableSensitiveDataLogging(); - options.EnableDetailedErrors(); - }); - }); - - factory.ConfigureServicesAfterStartup(_afterServicesConfiguration); - - using IServiceScope scope = factory.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.Database.EnsureCreated(); - - return factory; - } - - public void Dispose() - { - RunOnDatabaseAsync(async context => await context.Database.EnsureDeletedAsync()).Wait(); - - Factory.Dispose(); - } - - public void ConfigureLogging(Action loggingConfiguration) - { - _loggingConfiguration = loggingConfiguration; - } - - public void ConfigureServicesBeforeStartup(Action servicesConfiguration) - { - _beforeServicesConfiguration = servicesConfiguration; - } - - public void ConfigureServicesAfterStartup(Action servicesConfiguration) - { - _afterServicesConfiguration = servicesConfiguration; - } - - public async Task RunOnDatabaseAsync(Func asyncAction) - { - using IServiceScope scope = Factory.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - - await asyncAction(dbContext); - } - - public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> - ExecuteGetAsync(string requestUrl, - IEnumerable acceptHeaders = null) - { - return await ExecuteRequestAsync(HttpMethod.Get, requestUrl, null, null, acceptHeaders); - } - - public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> - ExecutePostAsync(string requestUrl, object requestBody, - string contentType = HeaderConstants.MediaType, - 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, - IEnumerable acceptHeaders = null) - { - return await ExecuteRequestAsync(HttpMethod.Patch, requestUrl, requestBody, contentType, - acceptHeaders); - } - - public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> - ExecuteDeleteAsync(string requestUrl, object requestBody = null, - string contentType = HeaderConstants.MediaType, - IEnumerable acceptHeaders = null) - { - return await ExecuteRequestAsync(HttpMethod.Delete, requestUrl, requestBody, contentType, - acceptHeaders); - } - - private async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> - ExecuteRequestAsync(HttpMethod method, string requestUrl, object requestBody, - string contentType, IEnumerable acceptHeaders) - { - var request = new HttpRequestMessage(method, requestUrl); - string requestText = SerializeRequest(requestBody); - - if (!string.IsNullOrEmpty(requestText)) - { - request.Content = new StringContent(requestText); - - if (contentType != null) - { - request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); - } - } - - using HttpClient client = Factory.CreateClient(); - - if (acceptHeaders != null) - { - foreach (var acceptHeader in acceptHeaders) - { - client.DefaultRequestHeaders.Accept.Add(acceptHeader); - } - } - - HttpResponseMessage responseMessage = await client.SendAsync(request); - - string responseText = await responseMessage.Content.ReadAsStringAsync(); - var responseDocument = DeserializeResponse(responseText); - - return (responseMessage, responseDocument); - } - - private string SerializeRequest(object requestBody) - { - return requestBody == null - ? null - : requestBody is string stringRequestBody - ? stringRequestBody - : JsonConvert.SerializeObject(requestBody); - } - - private TResponseDocument DeserializeResponse(string responseText) - { - if (typeof(TResponseDocument) == typeof(string)) - { - return (TResponseDocument)(object)responseText; - } - - try - { - return JsonConvert.DeserializeObject(responseText, - IntegrationTestConfiguration.DeserializationSettings); - } - catch (JsonException exception) - { - throw new FormatException($"Failed to deserialize response body to JSON:\n{responseText}", exception); - } - } - - private sealed class IntegrationTestWebApplicationFactory : WebApplicationFactory - { - private Action _loggingConfiguration; - private Action _beforeServicesConfiguration; - private Action _afterServicesConfiguration; - - public void ConfigureLogging(Action loggingConfiguration) - { - _loggingConfiguration = loggingConfiguration; - } - - public void ConfigureServicesBeforeStartup(Action servicesConfiguration) - { - _beforeServicesConfiguration = servicesConfiguration; - } - - public void ConfigureServicesAfterStartup(Action servicesConfiguration) - { - _afterServicesConfiguration = servicesConfiguration; - } - - protected override IHostBuilder CreateHostBuilder() - { - return Host.CreateDefaultBuilder(null) - .ConfigureWebHostDefaults(webBuilder => - { - webBuilder.ConfigureLogging(options => - { - _loggingConfiguration?.Invoke(options); - }); - - webBuilder.ConfigureServices(services => - { - _beforeServicesConfiguration?.Invoke(services); - }); - - webBuilder.UseStartup(); - - webBuilder.ConfigureServices(services => - { - _afterServicesConfiguration?.Invoke(services); - }); - }); - } - } } } diff --git a/test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj b/test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj index e03f87d3e5..77e604e150 100644 --- a/test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj +++ b/test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj @@ -11,13 +11,11 @@ + - - - diff --git a/test/JsonApiDotNetCoreExampleTests/Startups/AbsoluteLinksInApiNamespaceStartup.cs b/test/JsonApiDotNetCoreExampleTests/Startups/AbsoluteLinksInApiNamespaceStartup.cs index 1f71c8b9e5..a9950b3da7 100644 --- a/test/JsonApiDotNetCoreExampleTests/Startups/AbsoluteLinksInApiNamespaceStartup.cs +++ b/test/JsonApiDotNetCoreExampleTests/Startups/AbsoluteLinksInApiNamespaceStartup.cs @@ -1,5 +1,4 @@ using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCoreExampleTests.IntegrationTests; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; diff --git a/test/JsonApiDotNetCoreExampleTests/Startups/AbsoluteLinksNoNamespaceStartup.cs b/test/JsonApiDotNetCoreExampleTests/Startups/AbsoluteLinksNoNamespaceStartup.cs index 19b76f9146..636a84ca14 100644 --- a/test/JsonApiDotNetCoreExampleTests/Startups/AbsoluteLinksNoNamespaceStartup.cs +++ b/test/JsonApiDotNetCoreExampleTests/Startups/AbsoluteLinksNoNamespaceStartup.cs @@ -1,5 +1,4 @@ using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCoreExampleTests.IntegrationTests; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; diff --git a/test/JsonApiDotNetCoreExampleTests/Startups/ModelStateValidationStartup.cs b/test/JsonApiDotNetCoreExampleTests/Startups/ModelStateValidationStartup.cs index 8114edfb1c..8816d19f21 100644 --- a/test/JsonApiDotNetCoreExampleTests/Startups/ModelStateValidationStartup.cs +++ b/test/JsonApiDotNetCoreExampleTests/Startups/ModelStateValidationStartup.cs @@ -1,5 +1,4 @@ using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCoreExampleTests.IntegrationTests; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; diff --git a/test/JsonApiDotNetCoreExampleTests/Startups/RelativeLinksInApiNamespaceStartup.cs b/test/JsonApiDotNetCoreExampleTests/Startups/RelativeLinksInApiNamespaceStartup.cs index c38601a58e..f8e3b18814 100644 --- a/test/JsonApiDotNetCoreExampleTests/Startups/RelativeLinksInApiNamespaceStartup.cs +++ b/test/JsonApiDotNetCoreExampleTests/Startups/RelativeLinksInApiNamespaceStartup.cs @@ -1,5 +1,4 @@ using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCoreExampleTests.IntegrationTests; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; diff --git a/test/JsonApiDotNetCoreExampleTests/Startups/RelativeLinksNoNamespaceStartup.cs b/test/JsonApiDotNetCoreExampleTests/Startups/RelativeLinksNoNamespaceStartup.cs index b07f3d56c6..365f3454ca 100644 --- a/test/JsonApiDotNetCoreExampleTests/Startups/RelativeLinksNoNamespaceStartup.cs +++ b/test/JsonApiDotNetCoreExampleTests/Startups/RelativeLinksNoNamespaceStartup.cs @@ -1,5 +1,4 @@ using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCoreExampleTests.IntegrationTests; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; diff --git a/test/JsonApiDotNetCoreExampleTests/TestableStartup.cs b/test/JsonApiDotNetCoreExampleTests/Startups/TestableStartup.cs similarity index 100% rename from test/JsonApiDotNetCoreExampleTests/TestableStartup.cs rename to test/JsonApiDotNetCoreExampleTests/Startups/TestableStartup.cs diff --git a/test/MultiDbContextTests/MultiDbContextTests.csproj b/test/MultiDbContextTests/MultiDbContextTests.csproj index c319c91e7a..3e90eb5a54 100644 --- a/test/MultiDbContextTests/MultiDbContextTests.csproj +++ b/test/MultiDbContextTests/MultiDbContextTests.csproj @@ -11,13 +11,12 @@ + - - diff --git a/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj b/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj index f4f39b2be4..42964abb58 100644 --- a/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj +++ b/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj @@ -11,12 +11,12 @@ + - diff --git a/test/JsonApiDotNetCoreExampleTests/AppDbContextExtensions.cs b/test/TestBuildingBlocks/AppDbContextExtensions.cs similarity index 100% rename from test/JsonApiDotNetCoreExampleTests/AppDbContextExtensions.cs rename to test/TestBuildingBlocks/AppDbContextExtensions.cs diff --git a/test/TestBuildingBlocks/BaseIntegrationTestContext.cs b/test/TestBuildingBlocks/BaseIntegrationTestContext.cs new file mode 100644 index 0000000000..0e179e8006 --- /dev/null +++ b/test/TestBuildingBlocks/BaseIntegrationTestContext.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using JsonApiDotNetCore.Middleware; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace JsonApiDotNetCoreExampleTests +{ + /// + /// A test context that creates a new database and server instance before running tests and cleans up afterwards. + /// You can either use this as a fixture on your tests class (init/cleanup runs once before/after all tests) or + /// have your tests class inherit from it (init/cleanup runs once before/after each test). See + /// for details on shared context usage. + /// + /// The server Startup class, which can be defined in the test project. + /// The base class for , which MUST be defined in the API project. + /// The EF Core database context, which can be defined in the test project. + public abstract class BaseIntegrationTestContext : IDisposable + where TStartup : class + where TRemoteStartup : class + where TDbContext : DbContext + { + private readonly Lazy> _lazyFactory; + private Action _loggingConfiguration; + private Action _beforeServicesConfiguration; + private Action _afterServicesConfiguration; + + public WebApplicationFactory Factory => _lazyFactory.Value; + + protected BaseIntegrationTestContext() + { + _lazyFactory = new Lazy>(CreateFactory); + } + + private WebApplicationFactory CreateFactory() + { + string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; + string dbConnectionString = + $"Host=localhost;Port=5432;Database=JsonApiTest-{Guid.NewGuid():N};User ID=postgres;Password={postgresPassword}"; + + var factory = new IntegrationTestWebApplicationFactory(); + + factory.ConfigureLogging(_loggingConfiguration); + + factory.ConfigureServicesBeforeStartup(services => + { + _beforeServicesConfiguration?.Invoke(services); + + services.AddDbContext(options => + { + options.UseNpgsql(dbConnectionString, + postgresOptions => postgresOptions.SetPostgresVersion(new Version(9, 6))); + + options.EnableSensitiveDataLogging(); + options.EnableDetailedErrors(); + }); + }); + + factory.ConfigureServicesAfterStartup(_afterServicesConfiguration); + + using IServiceScope scope = factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + dbContext.Database.EnsureCreated(); + + return factory; + } + + public void Dispose() + { + RunOnDatabaseAsync(async context => await context.Database.EnsureDeletedAsync()).Wait(); + + Factory.Dispose(); + } + + public void ConfigureLogging(Action loggingConfiguration) + { + _loggingConfiguration = loggingConfiguration; + } + + public void ConfigureServicesBeforeStartup(Action servicesConfiguration) + { + _beforeServicesConfiguration = servicesConfiguration; + } + + public void ConfigureServicesAfterStartup(Action servicesConfiguration) + { + _afterServicesConfiguration = servicesConfiguration; + } + + public async Task RunOnDatabaseAsync(Func asyncAction) + { + using IServiceScope scope = Factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + await asyncAction(dbContext); + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> + ExecuteGetAsync(string requestUrl, + IEnumerable acceptHeaders = null) + { + return await ExecuteRequestAsync(HttpMethod.Get, requestUrl, null, null, acceptHeaders); + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> + ExecutePostAsync(string requestUrl, object requestBody, + string contentType = HeaderConstants.MediaType, + 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, + IEnumerable acceptHeaders = null) + { + return await ExecuteRequestAsync(HttpMethod.Patch, requestUrl, requestBody, contentType, + acceptHeaders); + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> + ExecuteDeleteAsync(string requestUrl, object requestBody = null, + string contentType = HeaderConstants.MediaType, + IEnumerable acceptHeaders = null) + { + return await ExecuteRequestAsync(HttpMethod.Delete, requestUrl, requestBody, contentType, + acceptHeaders); + } + + private async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> + ExecuteRequestAsync(HttpMethod method, string requestUrl, object requestBody, + string contentType, IEnumerable acceptHeaders) + { + var request = new HttpRequestMessage(method, requestUrl); + string requestText = SerializeRequest(requestBody); + + if (!string.IsNullOrEmpty(requestText)) + { + request.Content = new StringContent(requestText); + + if (contentType != null) + { + request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); + } + } + + using HttpClient client = Factory.CreateClient(); + + if (acceptHeaders != null) + { + foreach (var acceptHeader in acceptHeaders) + { + client.DefaultRequestHeaders.Accept.Add(acceptHeader); + } + } + + HttpResponseMessage responseMessage = await client.SendAsync(request); + + string responseText = await responseMessage.Content.ReadAsStringAsync(); + var responseDocument = DeserializeResponse(responseText); + + return (responseMessage, responseDocument); + } + + private string SerializeRequest(object requestBody) + { + return requestBody == null + ? null + : requestBody is string stringRequestBody + ? stringRequestBody + : JsonConvert.SerializeObject(requestBody); + } + + private TResponseDocument DeserializeResponse(string responseText) + { + if (typeof(TResponseDocument) == typeof(string)) + { + return (TResponseDocument)(object)responseText; + } + + try + { + return JsonConvert.DeserializeObject(responseText, + IntegrationTestConfiguration.DeserializationSettings); + } + catch (JsonException exception) + { + throw new FormatException($"Failed to deserialize response body to JSON:\n{responseText}", exception); + } + } + + private sealed class IntegrationTestWebApplicationFactory : WebApplicationFactory + { + private Action _loggingConfiguration; + private Action _beforeServicesConfiguration; + private Action _afterServicesConfiguration; + + public void ConfigureLogging(Action loggingConfiguration) + { + _loggingConfiguration = loggingConfiguration; + } + + public void ConfigureServicesBeforeStartup(Action servicesConfiguration) + { + _beforeServicesConfiguration = servicesConfiguration; + } + + public void ConfigureServicesAfterStartup(Action servicesConfiguration) + { + _afterServicesConfiguration = servicesConfiguration; + } + + protected override IHostBuilder CreateHostBuilder() + { + return Host.CreateDefaultBuilder(null) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.ConfigureLogging(options => + { + _loggingConfiguration?.Invoke(options); + }); + + webBuilder.ConfigureServices(services => + { + _beforeServicesConfiguration?.Invoke(services); + }); + + webBuilder.UseStartup(); + + webBuilder.ConfigureServices(services => + { + _afterServicesConfiguration?.Invoke(services); + }); + }); + } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/FakeLoggerFactory.cs b/test/TestBuildingBlocks/FakeLoggerFactory.cs similarity index 71% rename from test/JsonApiDotNetCoreExampleTests/FakeLoggerFactory.cs rename to test/TestBuildingBlocks/FakeLoggerFactory.cs index 4fb18a008b..1dd7b6ea5b 100644 --- a/test/JsonApiDotNetCoreExampleTests/FakeLoggerFactory.cs +++ b/test/TestBuildingBlocks/FakeLoggerFactory.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCoreExampleTests { - internal sealed class FakeLoggerFactory : ILoggerFactory, ILoggerProvider + public sealed class FakeLoggerFactory : ILoggerFactory, ILoggerProvider { public FakeLogger Logger { get; } @@ -24,11 +24,11 @@ public void Dispose() { } - internal sealed class FakeLogger : ILogger + public sealed class FakeLogger : ILogger { - private readonly ConcurrentBag _messages = new ConcurrentBag(); + private readonly ConcurrentBag _messages = new ConcurrentBag(); - public IReadOnlyCollection Messages => _messages; + public IReadOnlyCollection Messages => _messages; public bool IsEnabled(LogLevel logLevel) => true; @@ -41,18 +41,18 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except Func formatter) { var message = formatter(state, exception); - _messages.Add(new LogMessage(logLevel, message)); + _messages.Add(new FakeLogMessage(logLevel, message)); } public IDisposable BeginScope(TState state) => null; } - internal sealed class LogMessage + public sealed class FakeLogMessage { public LogLevel LogLevel { get; } public string Text { get; } - public LogMessage(LogLevel logLevel, string text) + public FakeLogMessage(LogLevel logLevel, string text) { LogLevel = logLevel; Text = text; diff --git a/test/JsonApiDotNetCoreExampleTests/FakerContainer.cs b/test/TestBuildingBlocks/FakerContainer.cs similarity index 98% rename from test/JsonApiDotNetCoreExampleTests/FakerContainer.cs rename to test/TestBuildingBlocks/FakerContainer.cs index be07fe5851..50dcab019e 100644 --- a/test/JsonApiDotNetCoreExampleTests/FakerContainer.cs +++ b/test/TestBuildingBlocks/FakerContainer.cs @@ -6,7 +6,7 @@ namespace JsonApiDotNetCoreExampleTests { - internal abstract class FakerContainer + public abstract class FakerContainer { protected static int GetFakerSeed() { diff --git a/test/JsonApiDotNetCoreExampleTests/FrozenSystemClock.cs b/test/TestBuildingBlocks/FrozenSystemClock.cs similarity index 84% rename from test/JsonApiDotNetCoreExampleTests/FrozenSystemClock.cs rename to test/TestBuildingBlocks/FrozenSystemClock.cs index f08b81b905..e907d0f0b4 100644 --- a/test/JsonApiDotNetCoreExampleTests/FrozenSystemClock.cs +++ b/test/TestBuildingBlocks/FrozenSystemClock.cs @@ -3,7 +3,7 @@ namespace JsonApiDotNetCoreExampleTests { - internal sealed class FrozenSystemClock : ISystemClock + public sealed class FrozenSystemClock : ISystemClock { private static readonly DateTimeOffset _defaultTime = new DateTimeOffset(new DateTime(2000, 1, 1, 1, 1, 1), TimeSpan.FromHours(1)); diff --git a/test/JsonApiDotNetCoreExampleTests/HttpResponseMessageExtensions.cs b/test/TestBuildingBlocks/HttpResponseMessageExtensions.cs similarity index 100% rename from test/JsonApiDotNetCoreExampleTests/HttpResponseMessageExtensions.cs rename to test/TestBuildingBlocks/HttpResponseMessageExtensions.cs diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTestConfiguration.cs b/test/TestBuildingBlocks/IntegrationTestConfiguration.cs similarity index 94% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTestConfiguration.cs rename to test/TestBuildingBlocks/IntegrationTestConfiguration.cs index 7f92b3ee67..d60fc18bf6 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTestConfiguration.cs +++ b/test/TestBuildingBlocks/IntegrationTestConfiguration.cs @@ -2,7 +2,7 @@ namespace JsonApiDotNetCoreExampleTests { - internal static class IntegrationTestConfiguration + public static class IntegrationTestConfiguration { // Because our tests often deserialize incoming responses into weakly-typed string-to-object dictionaries (as part of ResourceObject), // Newtonsoft.JSON is unable to infer the target type in such cases. So we steer a bit using explicit configuration. diff --git a/test/JsonApiDotNetCoreExampleTests/NeverSameResourceChangeTracker.cs b/test/TestBuildingBlocks/NeverSameResourceChangeTracker.cs similarity index 85% rename from test/JsonApiDotNetCoreExampleTests/NeverSameResourceChangeTracker.cs rename to test/TestBuildingBlocks/NeverSameResourceChangeTracker.cs index b518811dfd..d8304fc1cf 100644 --- a/test/JsonApiDotNetCoreExampleTests/NeverSameResourceChangeTracker.cs +++ b/test/TestBuildingBlocks/NeverSameResourceChangeTracker.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCoreExampleTests /// /// Ensures the resource attributes are returned when creating/updating a resource. /// - internal sealed class NeverSameResourceChangeTracker : IResourceChangeTracker + public sealed class NeverSameResourceChangeTracker : IResourceChangeTracker where TResource : class, IIdentifiable { public void SetInitiallyStoredAttributeValues(TResource resource) diff --git a/test/JsonApiDotNetCoreExampleTests/ObjectAssertionsExtensions.cs b/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs similarity index 100% rename from test/JsonApiDotNetCoreExampleTests/ObjectAssertionsExtensions.cs rename to test/TestBuildingBlocks/ObjectAssertionsExtensions.cs diff --git a/test/TestBuildingBlocks/TestBuildingBlocks.csproj b/test/TestBuildingBlocks/TestBuildingBlocks.csproj new file mode 100644 index 0000000000..42f159dfa1 --- /dev/null +++ b/test/TestBuildingBlocks/TestBuildingBlocks.csproj @@ -0,0 +1,19 @@ + + + $(NetCoreAppVersion) + + + + + + + + + + + + + + + + diff --git a/test/UnitTests/Data/EntityFrameworkCoreRepositoryTests.cs b/test/UnitTests/Data/EntityFrameworkCoreRepositoryTests.cs index a80e5c076d..d17980d1c3 100644 --- a/test/UnitTests/Data/EntityFrameworkCoreRepositoryTests.cs +++ b/test/UnitTests/Data/EntityFrameworkCoreRepositoryTests.cs @@ -11,6 +11,7 @@ using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using JsonApiDotNetCoreExampleTests; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index b42e16e701..c78d83ad88 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -14,6 +14,7 @@ using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using JsonApiDotNetCoreExampleTests; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; diff --git a/test/UnitTests/FakeLoggerFactory.cs b/test/UnitTests/FakeLoggerFactory.cs deleted file mode 100644 index 999230e214..0000000000 --- a/test/UnitTests/FakeLoggerFactory.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.Collections.Generic; -using Microsoft.Extensions.Logging; - -namespace UnitTests -{ - internal sealed class FakeLoggerFactory : ILoggerFactory, ILoggerProvider - { - public FakeLogger Logger { get; } - - public FakeLoggerFactory() - { - Logger = new FakeLogger(); - } - - public ILogger CreateLogger(string categoryName) => Logger; - - public void AddProvider(ILoggerProvider provider) - { - } - - public void Dispose() - { - } - - internal sealed class FakeLogger : ILogger - { - public List<(LogLevel LogLevel, string Text)> Messages = new List<(LogLevel, string)>(); - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, - Func formatter) - { - var message = formatter(state, exception); - Messages.Add((logLevel, message)); - } - - public bool IsEnabled(LogLevel logLevel) => true; - public IDisposable BeginScope(TState state) => null; - } - } -} diff --git a/test/UnitTests/FrozenSystemClock.cs b/test/UnitTests/FrozenSystemClock.cs deleted file mode 100644 index 218fe1cb54..0000000000 --- a/test/UnitTests/FrozenSystemClock.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using Microsoft.AspNetCore.Authentication; - -namespace UnitTests -{ - internal class FrozenSystemClock : ISystemClock - { - public DateTimeOffset UtcNow { get; } - - public FrozenSystemClock() - : this(new DateTimeOffset(new DateTime(2000, 1, 1))) - { - } - - public FrozenSystemClock(DateTimeOffset utcNow) - { - UtcNow = utcNow; - } - } -} diff --git a/test/UnitTests/Internal/ResourceGraphBuilderTests.cs b/test/UnitTests/Internal/ResourceGraphBuilderTests.cs index c5350640db..be665afe09 100644 --- a/test/UnitTests/Internal/ResourceGraphBuilderTests.cs +++ b/test/UnitTests/Internal/ResourceGraphBuilderTests.cs @@ -1,6 +1,8 @@ +using System.Linq; using Castle.DynamicProxy; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; +using JsonApiDotNetCoreExampleTests; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -37,8 +39,8 @@ public void Adding_DbContext_Members_That_Do_Not_Implement_IIdentifiable_Logs_Wa // Assert Assert.Single(loggerFactory.Logger.Messages); - Assert.Equal(LogLevel.Warning, loggerFactory.Logger.Messages[0].LogLevel); - Assert.Equal("Entity 'UnitTests.Internal.ResourceGraphBuilderTests+TestContext' does not implement 'IIdentifiable'.", loggerFactory.Logger.Messages[0].Text); + Assert.Equal(LogLevel.Warning, loggerFactory.Logger.Messages.Single().LogLevel); + Assert.Equal("Entity 'UnitTests.Internal.ResourceGraphBuilderTests+TestContext' does not implement 'IIdentifiable'.", loggerFactory.Logger.Messages.Single().Text); } [Fact] diff --git a/test/UnitTests/Models/ResourceConstructionExpressionTests.cs b/test/UnitTests/Models/ResourceConstructionExpressionTests.cs index 242ee0feef..b4663146cb 100644 --- a/test/UnitTests/Models/ResourceConstructionExpressionTests.cs +++ b/test/UnitTests/Models/ResourceConstructionExpressionTests.cs @@ -3,6 +3,7 @@ using System.Linq.Expressions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExampleTests; using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; using Xunit; diff --git a/test/UnitTests/Models/ResourceConstructionTests.cs b/test/UnitTests/Models/ResourceConstructionTests.cs index 6fca6fc3ec..b21bec2641 100644 --- a/test/UnitTests/Models/ResourceConstructionTests.cs +++ b/test/UnitTests/Models/ResourceConstructionTests.cs @@ -5,6 +5,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExampleTests; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; diff --git a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs index 017e61fcb1..d1b50b8ace 100644 --- a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs @@ -15,6 +15,7 @@ using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using JsonApiDotNetCoreExampleTests; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; diff --git a/test/UnitTests/UnitTests.csproj b/test/UnitTests/UnitTests.csproj index 7d4cc30981..f4773c8b2b 100644 --- a/test/UnitTests/UnitTests.csproj +++ b/test/UnitTests/UnitTests.csproj @@ -11,15 +11,13 @@ + - - - From db9778e2790bc2119b67f97fb3ba7911fb3d82e1 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 4 Feb 2021 14:56:19 +0100 Subject: [PATCH 092/123] Auto-adjust namespaces --- test/JsonApiDotNetCoreExampleTests/ExampleFakers.cs | 1 + test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs | 1 + .../IntegrationTests/CompositeKeys/CompositeKeyTests.cs | 1 + .../IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs | 1 + .../ContentNegotiation/ContentTypeHeaderTests.cs | 1 + .../ControllerActionResults/ActionResultTests.cs | 1 + .../CustomRoutes/ApiControllerAttributeTests.cs | 1 + .../IntegrationTests/CustomRoutes/CustomRouteFakers.cs | 1 + .../IntegrationTests/CustomRoutes/CustomRouteTests.cs | 1 + .../IntegrationTests/EagerLoading/EagerLoadingFakers.cs | 1 + .../IntegrationTests/EagerLoading/EagerLoadingTests.cs | 1 + .../IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs | 1 + .../IntegrationTests/Filtering/FilterDataTypeTests.cs | 1 + .../IntegrationTests/Filtering/FilterDepthTests.cs | 1 + .../IntegrationTests/Filtering/FilterOperatorTests.cs | 1 + .../IntegrationTests/Filtering/FilterTests.cs | 1 + .../IntegrationTests/IdObfuscation/IdObfuscationTests.cs | 1 + .../IntegrationTests/IdObfuscation/ObfuscationFakers.cs | 1 + .../IntegrationTests/Includes/IncludeTests.cs | 1 + .../IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs | 1 + .../Links/AbsoluteLinksWithoutNamespaceTests.cs | 1 + .../IntegrationTests/Links/LinksFakers.cs | 1 + .../IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs | 1 + .../Links/RelativeLinksWithoutNamespaceTests.cs | 1 + .../IntegrationTests/Logging/LoggingTests.cs | 1 + .../IntegrationTests/Meta/ResourceMetaTests.cs | 1 + .../IntegrationTests/Meta/ResponseMetaTests.cs | 1 + .../IntegrationTests/Meta/TopLevelCountTests.cs | 1 + .../ModelStateValidation/ModelStateValidationTests.cs | 1 + .../ModelStateValidation/NoModelStateValidationTests.cs | 1 + .../IntegrationTests/NamingConventions/KebabCasingTests.cs | 1 + .../IntegrationTests/NamingConventions/SwimmingFakers.cs | 1 + .../NonJsonApiControllers/NonJsonApiControllerTests.cs | 1 + .../Pagination/PaginationWithTotalCountTests.cs | 1 + .../Pagination/PaginationWithoutTotalCountTests.cs | 1 + .../IntegrationTests/Pagination/RangeValidationTests.cs | 1 + .../Pagination/RangeValidationWithMaximumTests.cs | 1 + .../IntegrationTests/QueryStrings/QueryStringFakers.cs | 1 + .../IntegrationTests/QueryStrings/QueryStringTests.cs | 1 + .../QueryStrings/SerializerDefaultValueHandlingTests.cs | 1 + .../QueryStrings/SerializerNullValueHandlingTests.cs | 1 + .../IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs | 1 + .../Creating/CreateResourceWithClientGeneratedIdTests.cs | 1 + .../Creating/CreateResourceWithToManyRelationshipTests.cs | 1 + .../Creating/CreateResourceWithToOneRelationshipTests.cs | 1 + .../IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs | 1 + .../ReadWrite/Fetching/FetchRelationshipTests.cs | 1 + .../IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs | 1 + .../IntegrationTests/ReadWrite/ReadWriteFakers.cs | 1 + .../Updating/Relationships/AddToToManyRelationshipTests.cs | 1 + .../Updating/Relationships/RemoveFromToManyRelationshipTests.cs | 1 + .../Updating/Relationships/ReplaceToManyRelationshipTests.cs | 1 + .../Updating/Relationships/UpdateToOneRelationshipTests.cs | 1 + .../Updating/Resources/ReplaceToManyRelationshipTests.cs | 1 + .../ReadWrite/Updating/Resources/UpdateResourceTests.cs | 1 + .../Updating/Resources/UpdateToOneRelationshipTests.cs | 1 + .../RequiredRelationships/DefaultBehaviorFakers.cs | 1 + .../RequiredRelationships/DefaultBehaviorTests.cs | 1 + .../ResourceConstructorInjection/InjectionFakers.cs | 1 + .../ResourceConstructorInjection/ResourceInjectionTests.cs | 1 + .../ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs | 1 + .../IntegrationTests/ResourceHooks/ResourceHookTests.cs | 1 + .../IntegrationTests/ResourceHooks/ResourceHooksStartup.cs | 1 + .../IntegrationTests/ResourceInheritance/InheritanceTests.cs | 1 + .../RestrictedControllers/DisableQueryStringTests.cs | 1 + .../IntegrationTests/RestrictedControllers/HttpReadOnlyTests.cs | 1 + .../IntegrationTests/RestrictedControllers/NoHttpDeleteTests.cs | 1 + .../IntegrationTests/RestrictedControllers/NoHttpPatchTests.cs | 1 + .../IntegrationTests/RestrictedControllers/NoHttpPostTests.cs | 1 + .../IntegrationTests/RestrictedControllers/RestrictionFakers.cs | 1 + .../IntegrationTests/Serialization/SerializationFakers.cs | 1 + .../IntegrationTests/Serialization/SerializationTests.cs | 1 + .../IntegrationTests/SoftDeletion/SoftDeletionTests.cs | 1 + .../IntegrationTests/Sorting/SortTests.cs | 1 + .../IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs | 1 + .../IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs | 1 + .../IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs | 1 + .../IntegrationTests/ZeroKeys/ZeroKeyFakers.cs | 1 + test/TestBuildingBlocks/AppDbContextExtensions.cs | 2 +- test/TestBuildingBlocks/BaseIntegrationTestContext.cs | 2 +- test/TestBuildingBlocks/FakeLoggerFactory.cs | 2 +- test/TestBuildingBlocks/FakerContainer.cs | 2 +- test/TestBuildingBlocks/FrozenSystemClock.cs | 2 +- test/TestBuildingBlocks/HttpResponseMessageExtensions.cs | 2 +- test/TestBuildingBlocks/IntegrationTestConfiguration.cs | 2 +- test/TestBuildingBlocks/NeverSameResourceChangeTracker.cs | 2 +- test/TestBuildingBlocks/ObjectAssertionsExtensions.cs | 2 +- test/UnitTests/Data/EntityFrameworkCoreRepositoryTests.cs | 2 +- test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs | 2 +- test/UnitTests/Internal/ResourceGraphBuilderTests.cs | 2 +- test/UnitTests/Models/ResourceConstructionExpressionTests.cs | 2 +- test/UnitTests/Models/ResourceConstructionTests.cs | 2 +- test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs | 2 +- 93 files changed, 93 insertions(+), 15 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/ExampleFakers.cs b/test/JsonApiDotNetCoreExampleTests/ExampleFakers.cs index eb33e1a141..a8908ad93e 100644 --- a/test/JsonApiDotNetCoreExampleTests/ExampleFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/ExampleFakers.cs @@ -3,6 +3,7 @@ using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Person = JsonApiDotNetCoreExample.Models.Person; namespace JsonApiDotNetCoreExampleTests diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs index 0632e170ae..87e79eb419 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs @@ -1,5 +1,6 @@ using JsonApiDotNetCoreExample; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; namespace JsonApiDotNetCoreExampleTests { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index b55f9ab1b0..37a783410a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs index 3a1019c249..7da9aea601 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ContentNegotiation diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs index 2cce83a8fe..fb8822a764 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs @@ -3,6 +3,7 @@ using FluentAssertions; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ContentNegotiation diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs index 2b0adc0580..fdcf1fb8b1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ControllerActionResults diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs index 3a38991118..6b39d8fa69 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteFakers.cs index e57febd9c4..0fa5c5d008 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteFakers.cs @@ -1,5 +1,6 @@ using System; using Bogus; +using TestBuildingBlocks; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs index e708cb8b13..a416e84a21 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingFakers.cs index 6ca3157f55..80ae5c9d14 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingFakers.cs @@ -1,5 +1,6 @@ using System; using Bogus; +using TestBuildingBlocks; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.EagerLoading { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs index c86e8f5bd8..57369d0c45 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs @@ -4,6 +4,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.EagerLoading diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs index 1c96c52dcd..1e0c61baa9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDataTypeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDataTypeTests.cs index d3f062b99a..1ed25c4b3b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDataTypeTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDataTypeTests.cs @@ -8,6 +8,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Filtering diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDepthTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDepthTests.cs index e497ab68dd..23f4036358 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDepthTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDepthTests.cs @@ -10,6 +10,7 @@ using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Filtering diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs index fb38aeee8d..d7ed5030e8 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs @@ -9,6 +9,7 @@ using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Filtering diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs index 6c4703f71d..f08231b2c3 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Filtering diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs index e6c9226cc4..40bea53580 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs @@ -3,6 +3,7 @@ using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs index 4d31f063da..06fd5ac9ab 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs @@ -1,5 +1,6 @@ using System; using Bogus; +using TestBuildingBlocks; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs index f97e5e6a1c..5c3a0e447a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs @@ -11,6 +11,7 @@ using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Includes diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs index 692bc950df..9a027ab7f5 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs index 56949c9b99..451b1bf7c7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksFakers.cs index 852c825b3c..6e751dd00b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksFakers.cs @@ -1,5 +1,6 @@ using System; using Bogus; +using TestBuildingBlocks; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs index e22eef9ce1..2a1d7c604b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs index e80bc9b17b..6054ee0e66 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs index 61c99f6bb7..d2f72569c2 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCoreExample.Data; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Logging diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs index ab33245072..30c415ff96 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs @@ -6,6 +6,7 @@ using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs index 73991d509f..d13df8b731 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs @@ -8,6 +8,7 @@ using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs index df6affafb5..edd4ac6d7b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs index 5ceb8d18ac..20024943c9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExampleTests.Startups; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs index 33619cb477..b7de6d3098 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs index 20bda54a9f..997ebe9aa0 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingFakers.cs index 71615bc567..8636078884 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingFakers.cs @@ -1,5 +1,6 @@ using System; using Bogus; +using TestBuildingBlocks; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs index e1e41af7be..768757e43f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs @@ -5,6 +5,7 @@ using FluentAssertions; using JsonApiDotNetCoreExample; using Microsoft.AspNetCore.Mvc.Testing; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NonJsonApiControllers diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs index d1ea71a2ea..ac398c8bb0 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs @@ -10,6 +10,7 @@ using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Pagination diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithoutTotalCountTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithoutTotalCountTests.cs index 2b9d71850d..ed3cf6a25c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithoutTotalCountTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithoutTotalCountTests.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Pagination diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationTests.cs index de65c323e2..718aafa7cd 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationTests.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Pagination diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationWithMaximumTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationWithMaximumTests.cs index a8ee41db71..ee44ec3ed5 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationWithMaximumTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationWithMaximumTests.cs @@ -6,6 +6,7 @@ using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Pagination diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringFakers.cs index 3a14f8f64e..aa7857110f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringFakers.cs @@ -1,5 +1,6 @@ using System; using Bogus; +using TestBuildingBlocks; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs index 2084a8dadb..8b61692981 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs @@ -4,6 +4,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerDefaultValueHandlingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerDefaultValueHandlingTests.cs index ec9c5275eb..3768a8501d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerDefaultValueHandlingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerDefaultValueHandlingTests.cs @@ -6,6 +6,7 @@ using JsonApiDotNetCore.Serialization.Objects; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerNullValueHandlingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerNullValueHandlingTests.cs index 9487ae3b9e..30ed412203 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerNullValueHandlingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerNullValueHandlingTests.cs @@ -6,6 +6,7 @@ using JsonApiDotNetCore.Serialization.Objects; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs index da2ecd70d6..cf1be53b1e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs @@ -8,6 +8,7 @@ using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Creating diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs index 74d21e5e66..ea3037926a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Creating diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs index 50d94940a8..d84310876c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Creating diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs index 2001171eaf..16bf2623ce 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs @@ -7,6 +7,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Creating diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs index dc3887d2d9..82343a9a3f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Deleting diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs index 1ca3e6a512..7ce2d71484 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Fetching diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs index 9d05882a92..0ccb85d25c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Fetching diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/ReadWriteFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/ReadWriteFakers.cs index 66da7ee498..cab209819c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/ReadWriteFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/ReadWriteFakers.cs @@ -1,5 +1,6 @@ using System; using Bogus; +using TestBuildingBlocks; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs index 4b1c9e90ca..701322743e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -5,6 +5,7 @@ using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Updating.Relationships diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index bc54d9d57a..e284e0edfc 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -5,6 +5,7 @@ using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Updating.Relationships diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs index ad9883b5e1..e0f901c4d8 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -5,6 +5,7 @@ using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Updating.Relationships diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs index d85d817e6f..fd9eeadad9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Updating.Relationships diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs index 3290752b3b..c4d083d534 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -5,6 +5,7 @@ using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Updating.Resources diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs index 2305fa532a..b44199bf14 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Updating.Resources diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs index f6661aad48..c489306d62 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Updating.Resources diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RequiredRelationships/DefaultBehaviorFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RequiredRelationships/DefaultBehaviorFakers.cs index 7c93242dcd..0363ddda5b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RequiredRelationships/DefaultBehaviorFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RequiredRelationships/DefaultBehaviorFakers.cs @@ -1,5 +1,6 @@ using System; using Bogus; +using TestBuildingBlocks; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RequiredRelationships { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs index 26869d87c6..df033d8c5f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs @@ -4,6 +4,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RequiredRelationships diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/InjectionFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/InjectionFakers.cs index 6dbf85a9c3..35aea4b060 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/InjectionFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/InjectionFakers.cs @@ -1,6 +1,7 @@ using System; using Bogus; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceConstructorInjection { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs index 17b3262114..dcec12f073 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceConstructorInjection diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs index 5a49ad59e5..d75a3a3774 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs index e798e1a3c6..d08615ad66 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs @@ -16,6 +16,7 @@ using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceHooks diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHooksStartup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHooksStartup.cs index fdcfa09376..c41f798a00 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHooksStartup.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHooksStartup.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceHooks { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs index f2c2c6001e..3f0fa157f2 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs @@ -5,6 +5,7 @@ using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceInheritance.Models; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceInheritance diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs index 15e3987762..f1dadcece0 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs @@ -4,6 +4,7 @@ using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/HttpReadOnlyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/HttpReadOnlyTests.cs index 2cefccbf8d..0ce068bf3d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/HttpReadOnlyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/HttpReadOnlyTests.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpDeleteTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpDeleteTests.cs index d8160f1b60..3fecae6275 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpDeleteTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpDeleteTests.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPatchTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPatchTests.cs index 62484888d0..d972d2f429 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPatchTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPatchTests.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPostTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPostTests.cs index d8f5fda941..eefdda4aa6 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPostTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPostTests.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/RestrictionFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/RestrictionFakers.cs index 70d2dab8a3..b81d66dda5 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/RestrictionFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/RestrictionFakers.cs @@ -1,5 +1,6 @@ using System; using Bogus; +using TestBuildingBlocks; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationFakers.cs index f884e23089..1bda09f4ae 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationFakers.cs @@ -1,5 +1,6 @@ using System; using Bogus; +using TestBuildingBlocks; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Serialization { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationTests.cs index 62a402f564..e90f0e1b50 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationTests.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Serialization diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs index 7aaf444c8c..874b1626fd 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs @@ -6,6 +6,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs index 950e0a752c..b0d60fed21 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs @@ -8,6 +8,7 @@ using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Sorting diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs index 445788b4f1..31ecebed2a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs @@ -11,6 +11,7 @@ using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SparseFieldSets diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs index 2e444baaf4..304d18b253 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs @@ -7,6 +7,7 @@ using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ZeroKeys diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs index 5b58daf39f..fa58257c4a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs @@ -6,6 +6,7 @@ using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ZeroKeys diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/ZeroKeyFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/ZeroKeyFakers.cs index 894f4d6482..300e76382b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/ZeroKeyFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/ZeroKeyFakers.cs @@ -1,5 +1,6 @@ using System; using Bogus; +using TestBuildingBlocks; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ZeroKeys { diff --git a/test/TestBuildingBlocks/AppDbContextExtensions.cs b/test/TestBuildingBlocks/AppDbContextExtensions.cs index 430cd60968..e518d103c0 100644 --- a/test/TestBuildingBlocks/AppDbContextExtensions.cs +++ b/test/TestBuildingBlocks/AppDbContextExtensions.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore; using Npgsql; -namespace JsonApiDotNetCoreExampleTests +namespace TestBuildingBlocks { public static class AppDbContextExtensions { diff --git a/test/TestBuildingBlocks/BaseIntegrationTestContext.cs b/test/TestBuildingBlocks/BaseIntegrationTestContext.cs index 0e179e8006..b0d61c1e59 100644 --- a/test/TestBuildingBlocks/BaseIntegrationTestContext.cs +++ b/test/TestBuildingBlocks/BaseIntegrationTestContext.cs @@ -12,7 +12,7 @@ using Microsoft.Extensions.Logging; using Newtonsoft.Json; -namespace JsonApiDotNetCoreExampleTests +namespace TestBuildingBlocks { /// /// A test context that creates a new database and server instance before running tests and cleans up afterwards. diff --git a/test/TestBuildingBlocks/FakeLoggerFactory.cs b/test/TestBuildingBlocks/FakeLoggerFactory.cs index 1dd7b6ea5b..f474e6a2e3 100644 --- a/test/TestBuildingBlocks/FakeLoggerFactory.cs +++ b/test/TestBuildingBlocks/FakeLoggerFactory.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExampleTests +namespace TestBuildingBlocks { public sealed class FakeLoggerFactory : ILoggerFactory, ILoggerProvider { diff --git a/test/TestBuildingBlocks/FakerContainer.cs b/test/TestBuildingBlocks/FakerContainer.cs index 50dcab019e..d377d1d448 100644 --- a/test/TestBuildingBlocks/FakerContainer.cs +++ b/test/TestBuildingBlocks/FakerContainer.cs @@ -4,7 +4,7 @@ using System.Reflection; using Xunit; -namespace JsonApiDotNetCoreExampleTests +namespace TestBuildingBlocks { public abstract class FakerContainer { diff --git a/test/TestBuildingBlocks/FrozenSystemClock.cs b/test/TestBuildingBlocks/FrozenSystemClock.cs index e907d0f0b4..91a361624e 100644 --- a/test/TestBuildingBlocks/FrozenSystemClock.cs +++ b/test/TestBuildingBlocks/FrozenSystemClock.cs @@ -1,7 +1,7 @@ using System; using Microsoft.AspNetCore.Authentication; -namespace JsonApiDotNetCoreExampleTests +namespace TestBuildingBlocks { public sealed class FrozenSystemClock : ISystemClock { diff --git a/test/TestBuildingBlocks/HttpResponseMessageExtensions.cs b/test/TestBuildingBlocks/HttpResponseMessageExtensions.cs index 8f550a4722..c8be07454a 100644 --- a/test/TestBuildingBlocks/HttpResponseMessageExtensions.cs +++ b/test/TestBuildingBlocks/HttpResponseMessageExtensions.cs @@ -6,7 +6,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; -namespace JsonApiDotNetCoreExampleTests +namespace TestBuildingBlocks { public static class HttpResponseMessageExtensions { diff --git a/test/TestBuildingBlocks/IntegrationTestConfiguration.cs b/test/TestBuildingBlocks/IntegrationTestConfiguration.cs index d60fc18bf6..32014a9630 100644 --- a/test/TestBuildingBlocks/IntegrationTestConfiguration.cs +++ b/test/TestBuildingBlocks/IntegrationTestConfiguration.cs @@ -1,6 +1,6 @@ using Newtonsoft.Json; -namespace JsonApiDotNetCoreExampleTests +namespace TestBuildingBlocks { public static class IntegrationTestConfiguration { diff --git a/test/TestBuildingBlocks/NeverSameResourceChangeTracker.cs b/test/TestBuildingBlocks/NeverSameResourceChangeTracker.cs index d8304fc1cf..a98fd5a8e2 100644 --- a/test/TestBuildingBlocks/NeverSameResourceChangeTracker.cs +++ b/test/TestBuildingBlocks/NeverSameResourceChangeTracker.cs @@ -1,6 +1,6 @@ using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCoreExampleTests +namespace TestBuildingBlocks { /// /// Ensures the resource attributes are returned when creating/updating a resource. diff --git a/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs b/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs index db75f5edd8..d13759e1b6 100644 --- a/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs +++ b/test/TestBuildingBlocks/ObjectAssertionsExtensions.cs @@ -4,7 +4,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; -namespace JsonApiDotNetCoreExampleTests +namespace TestBuildingBlocks { public static class ObjectAssertionsExtensions { diff --git a/test/UnitTests/Data/EntityFrameworkCoreRepositoryTests.cs b/test/UnitTests/Data/EntityFrameworkCoreRepositoryTests.cs index d17980d1c3..0d8b177ddc 100644 --- a/test/UnitTests/Data/EntityFrameworkCoreRepositoryTests.cs +++ b/test/UnitTests/Data/EntityFrameworkCoreRepositoryTests.cs @@ -11,11 +11,11 @@ using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using Moq; +using TestBuildingBlocks; using Xunit; namespace UnitTests.Data diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index c78d83ad88..bc6318b774 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -14,11 +14,11 @@ using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; using Xunit; namespace UnitTests.Extensions diff --git a/test/UnitTests/Internal/ResourceGraphBuilderTests.cs b/test/UnitTests/Internal/ResourceGraphBuilderTests.cs index be665afe09..ba1fc670b1 100644 --- a/test/UnitTests/Internal/ResourceGraphBuilderTests.cs +++ b/test/UnitTests/Internal/ResourceGraphBuilderTests.cs @@ -2,10 +2,10 @@ using Castle.DynamicProxy; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCoreExampleTests; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; +using TestBuildingBlocks; using Xunit; namespace UnitTests.Internal diff --git a/test/UnitTests/Models/ResourceConstructionExpressionTests.cs b/test/UnitTests/Models/ResourceConstructionExpressionTests.cs index b4663146cb..71a4f9642c 100644 --- a/test/UnitTests/Models/ResourceConstructionExpressionTests.cs +++ b/test/UnitTests/Models/ResourceConstructionExpressionTests.cs @@ -3,9 +3,9 @@ using System.Linq.Expressions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExampleTests; using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; using Xunit; namespace UnitTests.Models diff --git a/test/UnitTests/Models/ResourceConstructionTests.cs b/test/UnitTests/Models/ResourceConstructionTests.cs index b21bec2641..bfae197a83 100644 --- a/test/UnitTests/Models/ResourceConstructionTests.cs +++ b/test/UnitTests/Models/ResourceConstructionTests.cs @@ -5,12 +5,12 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExampleTests; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Newtonsoft.Json; +using TestBuildingBlocks; using Xunit; namespace UnitTests.Models diff --git a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs index d1b50b8ace..bcfdba7cf1 100644 --- a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs @@ -15,11 +15,11 @@ using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; -using JsonApiDotNetCoreExampleTests; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using Moq; +using TestBuildingBlocks; using Person = JsonApiDotNetCoreExample.Models.Person; namespace UnitTests.ResourceHooks From d9a2ac7863c0faf2d4ad28fcab9e6aad5c999463 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 4 Feb 2021 15:08:51 +0100 Subject: [PATCH 093/123] Refactored tests for service discovery --- .../ServiceDiscoveryFacadeTests.cs | 73 +++++++++++-------- 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 4c8141f974..6845430880 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Hooks; using JsonApiDotNetCore.Middleware; @@ -47,10 +48,10 @@ public ServiceDiscoveryFacadeTests() } [Fact] - public void DiscoverResources_Adds_Resources_From_Added_Assembly_To_Graph() + public void Can_add_resources_from_assembly_to_graph() { // Arrange - ServiceDiscoveryFacade facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, _loggerFactory); + var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, _loggerFactory); facade.AddAssembly(typeof(Person).Assembly); // Act @@ -58,17 +59,19 @@ public void DiscoverResources_Adds_Resources_From_Added_Assembly_To_Graph() // Assert var resourceGraph = _resourceGraphBuilder.Build(); + var personResource = resourceGraph.GetResourceContext(typeof(Person)); + personResource.Should().NotBeNull(); + var articleResource = resourceGraph.GetResourceContext(typeof(Article)); - Assert.NotNull(personResource); - Assert.NotNull(articleResource); + articleResource.Should().NotBeNull(); } [Fact] - public void DiscoverResources_Adds_Resources_From_Current_Assembly_To_Graph() + public void Can_add_resource_from_current_assembly_to_graph() { // Arrange - ServiceDiscoveryFacade facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, _loggerFactory); + var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, _loggerFactory); facade.AddCurrentAssembly(); // Act @@ -76,15 +79,16 @@ public void DiscoverResources_Adds_Resources_From_Current_Assembly_To_Graph() // Assert var resourceGraph = _resourceGraphBuilder.Build(); - var testModelResource = resourceGraph.GetResourceContext(typeof(TestModel)); - Assert.NotNull(testModelResource); + + var resource = resourceGraph.GetResourceContext(typeof(TestResource)); + resource.Should().NotBeNull(); } [Fact] - public void DiscoverInjectables_Adds_Resource_Services_From_Current_Assembly_To_Container() + public void Can_add_resource_service_from_current_assembly_to_container() { // Arrange - ServiceDiscoveryFacade facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, _loggerFactory); + var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, _loggerFactory); facade.AddCurrentAssembly(); // Act @@ -92,15 +96,16 @@ public void DiscoverInjectables_Adds_Resource_Services_From_Current_Assembly_To_ // Assert var services = _services.BuildServiceProvider(); - var service = services.GetRequiredService>(); - Assert.IsType(service); + + var resourceService = services.GetRequiredService>(); + resourceService.Should().BeOfType(); } [Fact] - public void DiscoverInjectables_Adds_Resource_Repositories_From_Current_Assembly_To_Container() + public void Can_add_resource_repository_from_current_assembly_to_container() { // Arrange - ServiceDiscoveryFacade facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, _loggerFactory); + var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, _loggerFactory); facade.AddCurrentAssembly(); // Act @@ -108,14 +113,16 @@ public void DiscoverInjectables_Adds_Resource_Repositories_From_Current_Assembly // Assert var services = _services.BuildServiceProvider(); - Assert.IsType(services.GetRequiredService>()); + + var resourceRepository = services.GetRequiredService>(); + resourceRepository.Should().BeOfType(); } [Fact] - public void AddCurrentAssembly_Adds_Resource_Definitions_From_Current_Assembly_To_Container() + public void Can_add_resource_definition_from_current_assembly_to_container() { // Arrange - ServiceDiscoveryFacade facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, _loggerFactory); + var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, _loggerFactory); facade.AddCurrentAssembly(); // Act @@ -123,14 +130,16 @@ public void AddCurrentAssembly_Adds_Resource_Definitions_From_Current_Assembly_T // Assert var services = _services.BuildServiceProvider(); - Assert.IsType(services.GetRequiredService>()); + + var resourceDefinition = services.GetRequiredService>(); + resourceDefinition.Should().BeOfType(); } [Fact] - public void AddCurrentAssembly_Adds_Resource_Hooks_Definitions_From_Current_Assembly_To_Container() + public void Can_add_resource_hooks_definition_from_current_assembly_to_container() { // Arrange - ServiceDiscoveryFacade facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, _loggerFactory); + var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, _loggerFactory); facade.AddCurrentAssembly(); _options.EnableResourceHooks = true; @@ -140,21 +149,23 @@ public void AddCurrentAssembly_Adds_Resource_Hooks_Definitions_From_Current_Asse // Assert var services = _services.BuildServiceProvider(); - Assert.IsType(services.GetRequiredService>()); + + var resourceHooksDefinition = services.GetRequiredService>(); + resourceHooksDefinition.Should().BeOfType(); } - public sealed class TestModel : Identifiable { } + public sealed class TestResource : Identifiable { } - public class TestModelService : JsonApiResourceService + public class TestResourceService : JsonApiResourceService { - public TestModelService( + public TestResourceService( IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, - IResourceChangeTracker resourceChangeTracker, + IResourceChangeTracker resourceChangeTracker, IResourceHookExecutorFacade hookExecutor) : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, hookExecutor) @@ -162,9 +173,9 @@ public TestModelService( } } - public class TestModelRepository : EntityFrameworkCoreRepository + public class TestResourceRepository : EntityFrameworkCoreRepository { - public TestModelRepository( + public TestResourceRepository( ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, @@ -175,14 +186,14 @@ public TestModelRepository( { } } - public class TestModelResourceHooksDefinition : ResourceHooksDefinition + public class TestResourceHooksDefinition : ResourceHooksDefinition { - public TestModelResourceHooksDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } + public TestResourceHooksDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } } - public class TestModelResourceDefinition : JsonApiResourceDefinition + public class TestResourceDefinition : JsonApiResourceDefinition { - public TestModelResourceDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } + public TestResourceDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } } } } From 53f8c82aca4bfc161979368646ced3e87e525636 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 4 Feb 2021 15:30:06 +0100 Subject: [PATCH 094/123] Refactored tests for no EF Core --- test/NoEntityFrameworkTests/WorkItemTests.cs | 113 ++++++------------ .../RemoteIntegrationTestContext.cs | 18 +++ 2 files changed, 52 insertions(+), 79 deletions(-) create mode 100644 test/TestBuildingBlocks/RemoteIntegrationTestContext.cs diff --git a/test/NoEntityFrameworkTests/WorkItemTests.cs b/test/NoEntityFrameworkTests/WorkItemTests.cs index f118483b76..f616691af3 100644 --- a/test/NoEntityFrameworkTests/WorkItemTests.cs +++ b/test/NoEntityFrameworkTests/WorkItemTests.cs @@ -1,167 +1,122 @@ using System; using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; using System.Threading.Tasks; -using JsonApiDotNetCore.Middleware; +using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; using NoEntityFrameworkExample; using NoEntityFrameworkExample.Data; using NoEntityFrameworkExample.Models; +using TestBuildingBlocks; using Xunit; namespace NoEntityFrameworkTests { - public sealed class WorkItemTests : IClassFixture> + public sealed class WorkItemTests : IClassFixture> { - private readonly WebApplicationFactory _factory; + private readonly RemoteIntegrationTestContext _testContext; - public WorkItemTests(WebApplicationFactory factory) + public WorkItemTests(RemoteIntegrationTestContext testContext) { - _factory = factory; + _testContext = testContext; } [Fact] - public async Task Can_Get_WorkItems() + public async Task Can_get_WorkItems() { // Arrange - await ExecuteOnDbContextAsync(async dbContext => + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.WorkItems.Add(new WorkItem()); await dbContext.SaveChangesAsync(); }); - var client = _factory.CreateClient(); - - var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/workItems"); + var route = "/api/v1/workItems"; // Act - var response = await client.SendAsync(request); + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); // Assert - AssertStatusCode(HttpStatusCode.OK, response); - - string responseBody = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(responseBody); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - Assert.NotEmpty(document.ManyData); + responseDocument.ManyData.Should().NotBeEmpty(); } [Fact] - public async Task Can_Get_WorkItem_By_Id() + public async Task Can_get_WorkItem_by_ID() { // Arrange var workItem = new WorkItem(); - await ExecuteOnDbContextAsync(async dbContext => + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.WorkItems.Add(workItem); await dbContext.SaveChangesAsync(); }); - var client = _factory.CreateClient(); - - var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/workItems/" + workItem.StringId); + var route = "/api/v1/workItems/" + workItem.StringId; // Act - var response = await client.SendAsync(request); + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); // Assert - AssertStatusCode(HttpStatusCode.OK, response); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - string responseBody = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(responseBody); - - Assert.NotNull(document.SingleData); - Assert.Equal(workItem.StringId, document.SingleData.Id); + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(workItem.StringId); } [Fact] - public async Task Can_Create_WorkItem() + public async Task Can_create_WorkItem() { // Arrange - var title = Guid.NewGuid().ToString(); + var newTitle = Guid.NewGuid().ToString(); - var requestContent = new + var requestBody = new { data = new { type = "workItems", attributes = new { - title, + title = newTitle, ordinal = 1 } } }; - var requestBody = JsonConvert.SerializeObject(requestContent); - - var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/workItems/") - { - Content = new StringContent(requestBody) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - var client = _factory.CreateClient(); + var route = "/api/v1/workItems/"; // Act - var response = await client.SendAsync(request); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - AssertStatusCode(HttpStatusCode.Created, response); - - string responseBody = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(responseBody); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - Assert.NotNull(document.SingleData); - Assert.Equal(title, document.SingleData.Attributes["title"]); + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["title"].Should().Be(newTitle); } [Fact] - public async Task Can_Delete_WorkItem() + public async Task Can_delete_WorkItem() { // Arrange var workItem = new WorkItem(); - await ExecuteOnDbContextAsync(async dbContext => + await _testContext.RunOnDatabaseAsync(async dbContext => { dbContext.WorkItems.Add(workItem); await dbContext.SaveChangesAsync(); }); - var client = _factory.CreateClient(); - - var request = new HttpRequestMessage(HttpMethod.Delete, "/api/v1/workItems/" + workItem.StringId); + var route = "/api/v1/workItems/" + workItem.StringId; // Act - var response = await client.SendAsync(request); + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); // Assert - AssertStatusCode(HttpStatusCode.NoContent, response); - - string responseBody = await response.Content.ReadAsStringAsync(); - Assert.Empty(responseBody); - } - - private async Task ExecuteOnDbContextAsync(Func asyncAction) - { - using IServiceScope scope = _factory.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - await asyncAction(dbContext); - } - - private static void AssertStatusCode(HttpStatusCode expected, HttpResponseMessage response) - { - if (expected != response.StatusCode) - { - var responseBody = response.Content.ReadAsStringAsync().Result; - Assert.True(expected == response.StatusCode, $"Got {response.StatusCode} status code instead of {expected}. Response body: {responseBody}"); - } + responseDocument.Should().BeEmpty(); } } } diff --git a/test/TestBuildingBlocks/RemoteIntegrationTestContext.cs b/test/TestBuildingBlocks/RemoteIntegrationTestContext.cs new file mode 100644 index 0000000000..d9555737d9 --- /dev/null +++ b/test/TestBuildingBlocks/RemoteIntegrationTestContext.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; + +namespace TestBuildingBlocks +{ + /// + /// A test context that creates a new database and server instance before running tests and cleans up afterwards. + /// You can either use this as a fixture on your tests class (init/cleanup runs once before/after all tests) or + /// have your tests class inherit from it (init/cleanup runs once before/after each test). See + /// for details on shared context usage. + /// + /// The server Startup class, which MUST be defined in the API project. + /// The EF Core database context, which MUST be defined in the API project. + public class RemoteIntegrationTestContext : BaseIntegrationTestContext + where TStartup : class + where TDbContext : DbContext + { + } +} From 86bac20f1beb9e97312fe4675d80af7c8bc9cbec Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 4 Feb 2021 15:48:56 +0100 Subject: [PATCH 095/123] Renamed IntegrationTestContext to ExampleIntegrationTestContext because it has a strong dependency on EmptyStartup from JsonApiDotNetCoreExamples project. --- .../ExampleIntegrationTestContext.cs | 17 ++++++++++++++++ .../IntegrationTestContext.cs | 20 ------------------- .../CompositeKeys/CompositeKeyTests.cs | 6 +++--- .../ContentNegotiation/AcceptHeaderTests.cs | 6 +++--- .../ContentTypeHeaderTests.cs | 6 +++--- .../ActionResultTests.cs | 6 +++--- .../ApiControllerAttributeTests.cs | 6 +++--- .../CustomRoutes/CustomRouteTests.cs | 6 +++--- .../EagerLoading/EagerLoadingTests.cs | 6 +++--- .../ExceptionHandlerTests.cs | 6 +++--- .../Filtering/FilterDataTypeTests.cs | 6 +++--- .../Filtering/FilterDepthTests.cs | 6 +++--- .../Filtering/FilterOperatorTests.cs | 6 +++--- .../IntegrationTests/Filtering/FilterTests.cs | 6 +++--- .../IdObfuscation/IdObfuscationTests.cs | 6 +++--- .../IntegrationTests/Includes/IncludeTests.cs | 6 +++--- .../Links/AbsoluteLinksWithNamespaceTests.cs | 6 +++--- .../AbsoluteLinksWithoutNamespaceTests.cs | 6 +++--- .../Links/RelativeLinksWithNamespaceTests.cs | 6 +++--- .../RelativeLinksWithoutNamespaceTests.cs | 6 +++--- .../IntegrationTests/Logging/LoggingTests.cs | 6 +++--- .../Meta/ResourceMetaTests.cs | 6 +++--- .../Meta/ResponseMetaTests.cs | 6 +++--- .../Meta/TopLevelCountTests.cs | 6 +++--- .../ModelStateValidationTests.cs | 6 +++--- .../NoModelStateValidationTests.cs | 6 +++--- .../NamingConventions/KebabCasingTests.cs | 6 +++--- .../PaginationWithTotalCountTests.cs | 6 +++--- .../PaginationWithoutTotalCountTests.cs | 6 +++--- .../Pagination/RangeValidationTests.cs | 6 +++--- .../RangeValidationWithMaximumTests.cs | 6 +++--- .../QueryStrings/QueryStringTests.cs | 6 +++--- .../SerializerDefaultValueHandlingTests.cs | 6 +++--- .../SerializerNullValueHandlingTests.cs | 6 +++--- .../ReadWrite/Creating/CreateResourceTests.cs | 6 +++--- ...reateResourceWithClientGeneratedIdTests.cs | 6 +++--- ...eateResourceWithToManyRelationshipTests.cs | 6 +++--- ...reateResourceWithToOneRelationshipTests.cs | 6 +++--- .../ReadWrite/Deleting/DeleteResourceTests.cs | 6 +++--- .../Fetching/FetchRelationshipTests.cs | 6 +++--- .../ReadWrite/Fetching/FetchResourceTests.cs | 6 +++--- .../AddToToManyRelationshipTests.cs | 6 +++--- .../RemoveFromToManyRelationshipTests.cs | 6 +++--- .../ReplaceToManyRelationshipTests.cs | 6 +++--- .../UpdateToOneRelationshipTests.cs | 6 +++--- .../ReplaceToManyRelationshipTests.cs | 6 +++--- .../Updating/Resources/UpdateResourceTests.cs | 6 +++--- .../Resources/UpdateToOneRelationshipTests.cs | 6 +++--- .../DefaultBehaviorTests.cs | 6 +++--- .../ResourceInjectionTests.cs | 6 +++--- .../ResourceDefinitionQueryCallbackTests.cs | 6 +++--- .../ResourceHooks/ResourceHookTests.cs | 6 +++--- .../ResourceHooks/ResourceHooksStartup.cs | 2 +- .../ResourceInheritance/InheritanceTests.cs | 6 +++--- .../DisableQueryStringTests.cs | 6 +++--- .../HttpReadOnlyTests.cs | 6 +++--- .../NoHttpDeleteTests.cs | 6 +++--- .../RestrictedControllers/NoHttpPatchTests.cs | 6 +++--- .../RestrictedControllers/NoHttpPostTests.cs | 6 +++--- .../Serialization/SerializationTests.cs | 6 +++--- .../SoftDeletion/SoftDeletionTests.cs | 6 +++--- .../IntegrationTests/Sorting/SortTests.cs | 6 +++--- .../SparseFieldSets/SparseFieldSetTests.cs | 6 +++--- .../ZeroKeys/EmptyGuidAsKeyTests.cs | 6 +++--- .../ZeroKeys/ZeroAsKeyTests.cs | 6 +++--- .../ServiceCollectionExtensions.cs | 2 +- .../BaseIntegrationTestContext.cs | 2 +- 67 files changed, 206 insertions(+), 209 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/ExampleIntegrationTestContext.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs diff --git a/test/JsonApiDotNetCoreExampleTests/ExampleIntegrationTestContext.cs b/test/JsonApiDotNetCoreExampleTests/ExampleIntegrationTestContext.cs new file mode 100644 index 0000000000..249b61cad4 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/ExampleIntegrationTestContext.cs @@ -0,0 +1,17 @@ +using JsonApiDotNetCoreExample; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; + +namespace JsonApiDotNetCoreExampleTests +{ + /// + /// A test context for tests that reference the JsonApiDotNetCoreExample project. + /// + /// The server Startup class, which can be defined in the test project. + /// The EF Core database context, which can be defined in the test project. + public class ExampleIntegrationTestContext : BaseIntegrationTestContext + where TStartup : class + where TDbContext : DbContext + { + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs deleted file mode 100644 index 87e79eb419..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTestContext.cs +++ /dev/null @@ -1,20 +0,0 @@ -using JsonApiDotNetCoreExample; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; - -namespace JsonApiDotNetCoreExampleTests -{ - /// - /// A test context that creates a new database and server instance before running tests and cleans up afterwards. - /// You can either use this as a fixture on your tests class (init/cleanup runs once before/after all tests) or - /// have your tests class inherit from it (init/cleanup runs once before/after each test). See - /// for details on shared context usage. - /// - /// The server Startup class, which can be defined in the test project. - /// The EF Core database context, which can be defined in the test project. - public class IntegrationTestContext : BaseIntegrationTestContext - where TStartup : class - where TDbContext : DbContext - { - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index 37a783410a..c177137cd7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -13,11 +13,11 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys { public sealed class CompositeKeyTests - : IClassFixture, CompositeDbContext>> + : IClassFixture, CompositeDbContext>> { - private readonly IntegrationTestContext, CompositeDbContext> _testContext; + private readonly ExampleIntegrationTestContext, CompositeDbContext> _testContext; - public CompositeKeyTests(IntegrationTestContext, CompositeDbContext> testContext) + public CompositeKeyTests(ExampleIntegrationTestContext, CompositeDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs index 7da9aea601..0169c316b5 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs @@ -10,11 +10,11 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ContentNegotiation { public sealed class AcceptHeaderTests - : IClassFixture, PolicyDbContext>> + : IClassFixture, PolicyDbContext>> { - private readonly IntegrationTestContext, PolicyDbContext> _testContext; + private readonly ExampleIntegrationTestContext, PolicyDbContext> _testContext; - public AcceptHeaderTests(IntegrationTestContext, PolicyDbContext> testContext) + public AcceptHeaderTests(ExampleIntegrationTestContext, PolicyDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs index fb8822a764..14367717ad 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs @@ -9,11 +9,11 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ContentNegotiation { public sealed class ContentTypeHeaderTests - : IClassFixture, PolicyDbContext>> + : IClassFixture, PolicyDbContext>> { - private readonly IntegrationTestContext, PolicyDbContext> _testContext; + private readonly ExampleIntegrationTestContext, PolicyDbContext> _testContext; - public ContentTypeHeaderTests(IntegrationTestContext, PolicyDbContext> testContext) + public ContentTypeHeaderTests(ExampleIntegrationTestContext, PolicyDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs index fdcf1fb8b1..fa6ccebf05 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs @@ -8,11 +8,11 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ControllerActionResults { public sealed class ActionResultTests - : IClassFixture, ActionResultDbContext>> + : IClassFixture, ActionResultDbContext>> { - private readonly IntegrationTestContext, ActionResultDbContext> _testContext; + private readonly ExampleIntegrationTestContext, ActionResultDbContext> _testContext; - public ActionResultTests(IntegrationTestContext, ActionResultDbContext> testContext) + public ActionResultTests(ExampleIntegrationTestContext, ActionResultDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs index 6b39d8fa69..4a3237ee9e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs @@ -8,11 +8,11 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes { public sealed class ApiControllerAttributeTests - : IClassFixture, CustomRouteDbContext>> + : IClassFixture, CustomRouteDbContext>> { - private readonly IntegrationTestContext, CustomRouteDbContext> _testContext; + private readonly ExampleIntegrationTestContext, CustomRouteDbContext> _testContext; - public ApiControllerAttributeTests(IntegrationTestContext, CustomRouteDbContext> testContext) + public ApiControllerAttributeTests(ExampleIntegrationTestContext, CustomRouteDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs index a416e84a21..5028008eab 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs @@ -9,12 +9,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes { public sealed class CustomRouteTests - : IClassFixture, CustomRouteDbContext>> + : IClassFixture, CustomRouteDbContext>> { - private readonly IntegrationTestContext, CustomRouteDbContext> _testContext; + private readonly ExampleIntegrationTestContext, CustomRouteDbContext> _testContext; private readonly CustomRouteFakers _fakers = new CustomRouteFakers(); - public CustomRouteTests(IntegrationTestContext, CustomRouteDbContext> testContext) + public CustomRouteTests(ExampleIntegrationTestContext, CustomRouteDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs index 57369d0c45..1dbe44d277 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs @@ -10,12 +10,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.EagerLoading { public sealed class EagerLoadingTests - : IClassFixture, EagerLoadingDbContext>> + : IClassFixture, EagerLoadingDbContext>> { - private readonly IntegrationTestContext, EagerLoadingDbContext> _testContext; + private readonly ExampleIntegrationTestContext, EagerLoadingDbContext> _testContext; private readonly EagerLoadingFakers _fakers = new EagerLoadingFakers(); - public EagerLoadingTests(IntegrationTestContext, EagerLoadingDbContext> testContext) + public EagerLoadingTests(ExampleIntegrationTestContext, EagerLoadingDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs index 1e0c61baa9..a03f9060aa 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs @@ -14,11 +14,11 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling { public sealed class ExceptionHandlerTests - : IClassFixture, ErrorDbContext>> + : IClassFixture, ErrorDbContext>> { - private readonly IntegrationTestContext, ErrorDbContext> _testContext; + private readonly ExampleIntegrationTestContext, ErrorDbContext> _testContext; - public ExceptionHandlerTests(IntegrationTestContext, ErrorDbContext> testContext) + public ExceptionHandlerTests(ExampleIntegrationTestContext, ErrorDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDataTypeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDataTypeTests.cs index 1ed25c4b3b..805fd8b513 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDataTypeTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDataTypeTests.cs @@ -13,11 +13,11 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Filtering { - public sealed class FilterDataTypeTests : IClassFixture, FilterDbContext>> + public sealed class FilterDataTypeTests : IClassFixture, FilterDbContext>> { - private readonly IntegrationTestContext, FilterDbContext> _testContext; + private readonly ExampleIntegrationTestContext, FilterDbContext> _testContext; - public FilterDataTypeTests(IntegrationTestContext, FilterDbContext> testContext) + public FilterDataTypeTests(ExampleIntegrationTestContext, FilterDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDepthTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDepthTests.cs index 23f4036358..a0748bf943 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDepthTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDepthTests.cs @@ -15,11 +15,11 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Filtering { - public sealed class FilterDepthTests : IClassFixture> + public sealed class FilterDepthTests : IClassFixture> { - private readonly IntegrationTestContext _testContext; + private readonly ExampleIntegrationTestContext _testContext; - public FilterDepthTests(IntegrationTestContext testContext) + public FilterDepthTests(ExampleIntegrationTestContext testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs index d7ed5030e8..71a6f54f21 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs @@ -14,11 +14,11 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Filtering { - public sealed class FilterOperatorTests : IClassFixture, FilterDbContext>> + public sealed class FilterOperatorTests : IClassFixture, FilterDbContext>> { - private readonly IntegrationTestContext, FilterDbContext> _testContext; + private readonly ExampleIntegrationTestContext, FilterDbContext> _testContext; - public FilterOperatorTests(IntegrationTestContext, FilterDbContext> testContext) + public FilterOperatorTests(ExampleIntegrationTestContext, FilterDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs index f08231b2c3..c60671a5f3 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs @@ -12,11 +12,11 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Filtering { - public sealed class FilterTests : IClassFixture> + public sealed class FilterTests : IClassFixture> { - private readonly IntegrationTestContext _testContext; + private readonly ExampleIntegrationTestContext _testContext; - public FilterTests(IntegrationTestContext testContext) + public FilterTests(ExampleIntegrationTestContext testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs index 40bea53580..ae8ab26b42 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs @@ -9,12 +9,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation { public sealed class IdObfuscationTests - : IClassFixture, ObfuscationDbContext>> + : IClassFixture, ObfuscationDbContext>> { - private readonly IntegrationTestContext, ObfuscationDbContext> _testContext; + private readonly ExampleIntegrationTestContext, ObfuscationDbContext> _testContext; private readonly ObfuscationFakers _fakers = new ObfuscationFakers(); - public IdObfuscationTests(IntegrationTestContext, ObfuscationDbContext> testContext) + public IdObfuscationTests(ExampleIntegrationTestContext, ObfuscationDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs index 5c3a0e447a..6afd1fd707 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs @@ -16,11 +16,11 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Includes { - public sealed class IncludeTests : IClassFixture> + public sealed class IncludeTests : IClassFixture> { - private readonly IntegrationTestContext _testContext; + private readonly ExampleIntegrationTestContext _testContext; - public IncludeTests(IntegrationTestContext testContext) + public IncludeTests(ExampleIntegrationTestContext testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs index 9a027ab7f5..a5fa5e1f26 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs @@ -13,12 +13,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links { public sealed class AbsoluteLinksWithNamespaceTests - : IClassFixture, LinksDbContext>> + : IClassFixture, LinksDbContext>> { - private readonly IntegrationTestContext, LinksDbContext> _testContext; + private readonly ExampleIntegrationTestContext, LinksDbContext> _testContext; private readonly LinksFakers _fakers = new LinksFakers(); - public AbsoluteLinksWithNamespaceTests(IntegrationTestContext, LinksDbContext> testContext) + public AbsoluteLinksWithNamespaceTests(ExampleIntegrationTestContext, LinksDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs index 451b1bf7c7..7de9adbaee 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs @@ -13,12 +13,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links { public sealed class AbsoluteLinksWithoutNamespaceTests - : IClassFixture, LinksDbContext>> + : IClassFixture, LinksDbContext>> { - private readonly IntegrationTestContext, LinksDbContext> _testContext; + private readonly ExampleIntegrationTestContext, LinksDbContext> _testContext; private readonly LinksFakers _fakers = new LinksFakers(); - public AbsoluteLinksWithoutNamespaceTests(IntegrationTestContext, LinksDbContext> testContext) + public AbsoluteLinksWithoutNamespaceTests(ExampleIntegrationTestContext, LinksDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs index 2a1d7c604b..e06f78f6a7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs @@ -13,12 +13,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links { public sealed class RelativeLinksWithNamespaceTests - : IClassFixture, LinksDbContext>> + : IClassFixture, LinksDbContext>> { - private readonly IntegrationTestContext, LinksDbContext> _testContext; + private readonly ExampleIntegrationTestContext, LinksDbContext> _testContext; private readonly LinksFakers _fakers = new LinksFakers(); - public RelativeLinksWithNamespaceTests(IntegrationTestContext, LinksDbContext> testContext) + public RelativeLinksWithNamespaceTests(ExampleIntegrationTestContext, LinksDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs index 6054ee0e66..dfbffa6123 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs @@ -13,12 +13,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links { public sealed class RelativeLinksWithoutNamespaceTests - : IClassFixture, LinksDbContext>> + : IClassFixture, LinksDbContext>> { - private readonly IntegrationTestContext, LinksDbContext> _testContext; + private readonly ExampleIntegrationTestContext, LinksDbContext> _testContext; private readonly LinksFakers _fakers = new LinksFakers(); - public RelativeLinksWithoutNamespaceTests(IntegrationTestContext, LinksDbContext> testContext) + public RelativeLinksWithoutNamespaceTests(ExampleIntegrationTestContext, LinksDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs index d2f72569c2..388c77aa58 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs @@ -12,11 +12,11 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Logging { - public sealed class LoggingTests : IClassFixture> + public sealed class LoggingTests : IClassFixture> { - private readonly IntegrationTestContext _testContext; + private readonly ExampleIntegrationTestContext _testContext; - public LoggingTests(IntegrationTestContext testContext) + public LoggingTests(ExampleIntegrationTestContext testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs index 30c415ff96..5fcd446864 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs @@ -11,11 +11,11 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta { - public sealed class ResourceMetaTests : IClassFixture> + public sealed class ResourceMetaTests : IClassFixture> { - private readonly IntegrationTestContext _testContext; + private readonly ExampleIntegrationTestContext _testContext; - public ResourceMetaTests(IntegrationTestContext testContext) + public ResourceMetaTests(ExampleIntegrationTestContext testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs index d13df8b731..f90f29223c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs @@ -13,11 +13,11 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta { - public sealed class ResponseMetaTests : IClassFixture> + public sealed class ResponseMetaTests : IClassFixture> { - private readonly IntegrationTestContext _testContext; + private readonly ExampleIntegrationTestContext _testContext; - public ResponseMetaTests(IntegrationTestContext testContext) + public ResponseMetaTests(ExampleIntegrationTestContext testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs index edd4ac6d7b..3b19ebb9fc 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs @@ -12,11 +12,11 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta { - public sealed class TopLevelCountTests : IClassFixture> + public sealed class TopLevelCountTests : IClassFixture> { - private readonly IntegrationTestContext _testContext; + private readonly ExampleIntegrationTestContext _testContext; - public TopLevelCountTests(IntegrationTestContext testContext) + public TopLevelCountTests(ExampleIntegrationTestContext testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs index 20024943c9..0d8176ff36 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -9,11 +9,11 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation { - public sealed class ModelStateValidationTests : IClassFixture, ModelStateDbContext>> + public sealed class ModelStateValidationTests : IClassFixture, ModelStateDbContext>> { - private readonly IntegrationTestContext, ModelStateDbContext> _testContext; + private readonly ExampleIntegrationTestContext, ModelStateDbContext> _testContext; - public ModelStateValidationTests(IntegrationTestContext, ModelStateDbContext> testContext) + public ModelStateValidationTests(ExampleIntegrationTestContext, ModelStateDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs index b7de6d3098..0923df55e1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs @@ -7,11 +7,11 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation { - public sealed class NoModelStateValidationTests : IClassFixture, ModelStateDbContext>> + public sealed class NoModelStateValidationTests : IClassFixture, ModelStateDbContext>> { - private readonly IntegrationTestContext, ModelStateDbContext> _testContext; + private readonly ExampleIntegrationTestContext, ModelStateDbContext> _testContext; - public NoModelStateValidationTests(IntegrationTestContext, ModelStateDbContext> testContext) + public NoModelStateValidationTests(ExampleIntegrationTestContext, ModelStateDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs index 997ebe9aa0..a19dee6ab3 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs @@ -10,12 +10,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions { public sealed class KebabCasingTests - : IClassFixture, SwimmingDbContext>> + : IClassFixture, SwimmingDbContext>> { - private readonly IntegrationTestContext, SwimmingDbContext> _testContext; + private readonly ExampleIntegrationTestContext, SwimmingDbContext> _testContext; private readonly SwimmingFakers _fakers = new SwimmingFakers(); - public KebabCasingTests(IntegrationTestContext, SwimmingDbContext> testContext) + public KebabCasingTests(ExampleIntegrationTestContext, SwimmingDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs index ac398c8bb0..fb9d31449b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs @@ -15,14 +15,14 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Pagination { - public sealed class PaginationWithTotalCountTests : IClassFixture> + public sealed class PaginationWithTotalCountTests : IClassFixture> { private const int _defaultPageSize = 5; - private readonly IntegrationTestContext _testContext; + private readonly ExampleIntegrationTestContext _testContext; private readonly ExampleFakers _fakers; - public PaginationWithTotalCountTests(IntegrationTestContext testContext) + public PaginationWithTotalCountTests(ExampleIntegrationTestContext testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithoutTotalCountTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithoutTotalCountTests.cs index ed3cf6a25c..2f291af433 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithoutTotalCountTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithoutTotalCountTests.cs @@ -12,14 +12,14 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Pagination { - public sealed class PaginationWithoutTotalCountTests : IClassFixture> + public sealed class PaginationWithoutTotalCountTests : IClassFixture> { private const int _defaultPageSize = 5; - private readonly IntegrationTestContext _testContext; + private readonly ExampleIntegrationTestContext _testContext; private readonly ExampleFakers _fakers; - public PaginationWithoutTotalCountTests(IntegrationTestContext testContext) + public PaginationWithoutTotalCountTests(ExampleIntegrationTestContext testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationTests.cs index 718aafa7cd..e9974a3d29 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationTests.cs @@ -12,14 +12,14 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Pagination { - public sealed class RangeValidationTests : IClassFixture> + public sealed class RangeValidationTests : IClassFixture> { - private readonly IntegrationTestContext _testContext; + private readonly ExampleIntegrationTestContext _testContext; private readonly ExampleFakers _fakers; private const int _defaultPageSize = 5; - public RangeValidationTests(IntegrationTestContext testContext) + public RangeValidationTests(ExampleIntegrationTestContext testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationWithMaximumTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationWithMaximumTests.cs index ee44ec3ed5..62f773be25 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationWithMaximumTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationWithMaximumTests.cs @@ -11,14 +11,14 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Pagination { - public sealed class RangeValidationWithMaximumTests : IClassFixture> + public sealed class RangeValidationWithMaximumTests : IClassFixture> { - private readonly IntegrationTestContext _testContext; + private readonly ExampleIntegrationTestContext _testContext; private const int _maximumPageSize = 15; private const int _maximumPageNumber = 20; - public RangeValidationWithMaximumTests(IntegrationTestContext testContext) + public RangeValidationWithMaximumTests(ExampleIntegrationTestContext testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs index 8b61692981..b3a5db84c8 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs @@ -10,12 +10,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings { public sealed class QueryStringTests - : IClassFixture, QueryStringDbContext>> + : IClassFixture, QueryStringDbContext>> { - private readonly IntegrationTestContext, QueryStringDbContext> _testContext; + private readonly ExampleIntegrationTestContext, QueryStringDbContext> _testContext; private readonly QueryStringFakers _fakers = new QueryStringFakers(); - public QueryStringTests(IntegrationTestContext, QueryStringDbContext> testContext) + public QueryStringTests(ExampleIntegrationTestContext, QueryStringDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerDefaultValueHandlingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerDefaultValueHandlingTests.cs index 3768a8501d..829cd5de4d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerDefaultValueHandlingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerDefaultValueHandlingTests.cs @@ -12,12 +12,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings { public sealed class SerializerDefaultValueHandlingTests - : IClassFixture, QueryStringDbContext>> + : IClassFixture, QueryStringDbContext>> { - private readonly IntegrationTestContext, QueryStringDbContext> _testContext; + private readonly ExampleIntegrationTestContext, QueryStringDbContext> _testContext; private readonly QueryStringFakers _fakers = new QueryStringFakers(); - public SerializerDefaultValueHandlingTests(IntegrationTestContext, QueryStringDbContext> testContext) + public SerializerDefaultValueHandlingTests(ExampleIntegrationTestContext, QueryStringDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerNullValueHandlingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerNullValueHandlingTests.cs index 30ed412203..aaa2029c6b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerNullValueHandlingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerNullValueHandlingTests.cs @@ -12,12 +12,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings { public sealed class SerializerNullValueHandlingTests - : IClassFixture, QueryStringDbContext>> + : IClassFixture, QueryStringDbContext>> { - private readonly IntegrationTestContext, QueryStringDbContext> _testContext; + private readonly ExampleIntegrationTestContext, QueryStringDbContext> _testContext; private readonly QueryStringFakers _fakers = new QueryStringFakers(); - public SerializerNullValueHandlingTests(IntegrationTestContext, QueryStringDbContext> testContext) + public SerializerNullValueHandlingTests(ExampleIntegrationTestContext, QueryStringDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs index cf1be53b1e..9c5d5e775b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs @@ -14,12 +14,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Creating { public sealed class CreateResourceTests - : IClassFixture, ReadWriteDbContext>> + : IClassFixture, ReadWriteDbContext>> { - private readonly IntegrationTestContext, ReadWriteDbContext> _testContext; + private readonly ExampleIntegrationTestContext, ReadWriteDbContext> _testContext; private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); - public CreateResourceTests(IntegrationTestContext, ReadWriteDbContext> testContext) + public CreateResourceTests(ExampleIntegrationTestContext, ReadWriteDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs index ea3037926a..86ad8fd335 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -13,12 +13,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Creating { public sealed class CreateResourceWithClientGeneratedIdTests - : IClassFixture, ReadWriteDbContext>> + : IClassFixture, ReadWriteDbContext>> { - private readonly IntegrationTestContext, ReadWriteDbContext> _testContext; + private readonly ExampleIntegrationTestContext, ReadWriteDbContext> _testContext; private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); - public CreateResourceWithClientGeneratedIdTests(IntegrationTestContext, ReadWriteDbContext> testContext) + public CreateResourceWithClientGeneratedIdTests(ExampleIntegrationTestContext, ReadWriteDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs index d84310876c..558c375149 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs @@ -10,12 +10,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Creating { public sealed class CreateResourceWithToManyRelationshipTests - : IClassFixture, ReadWriteDbContext>> + : IClassFixture, ReadWriteDbContext>> { - private readonly IntegrationTestContext, ReadWriteDbContext> _testContext; + private readonly ExampleIntegrationTestContext, ReadWriteDbContext> _testContext; private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); - public CreateResourceWithToManyRelationshipTests(IntegrationTestContext, ReadWriteDbContext> testContext) + public CreateResourceWithToManyRelationshipTests(ExampleIntegrationTestContext, ReadWriteDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs index 16bf2623ce..ad8865d712 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs @@ -13,12 +13,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Creating { public sealed class CreateResourceWithToOneRelationshipTests - : IClassFixture, ReadWriteDbContext>> + : IClassFixture, ReadWriteDbContext>> { - private readonly IntegrationTestContext, ReadWriteDbContext> _testContext; + private readonly ExampleIntegrationTestContext, ReadWriteDbContext> _testContext; private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); - public CreateResourceWithToOneRelationshipTests(IntegrationTestContext, ReadWriteDbContext> testContext) + public CreateResourceWithToOneRelationshipTests(ExampleIntegrationTestContext, ReadWriteDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs index 82343a9a3f..05f1ac71b7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs @@ -10,12 +10,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Deleting { public sealed class DeleteResourceTests - : IClassFixture, ReadWriteDbContext>> + : IClassFixture, ReadWriteDbContext>> { - private readonly IntegrationTestContext, ReadWriteDbContext> _testContext; + private readonly ExampleIntegrationTestContext, ReadWriteDbContext> _testContext; private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); - public DeleteResourceTests(IntegrationTestContext, ReadWriteDbContext> testContext) + public DeleteResourceTests(ExampleIntegrationTestContext, ReadWriteDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs index 7ce2d71484..fc34b593d2 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs @@ -10,12 +10,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Fetching { public sealed class FetchRelationshipTests - : IClassFixture, ReadWriteDbContext>> + : IClassFixture, ReadWriteDbContext>> { - private readonly IntegrationTestContext, ReadWriteDbContext> _testContext; + private readonly ExampleIntegrationTestContext, ReadWriteDbContext> _testContext; private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); - public FetchRelationshipTests(IntegrationTestContext, ReadWriteDbContext> testContext) + public FetchRelationshipTests(ExampleIntegrationTestContext, ReadWriteDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs index 0ccb85d25c..57c49860c3 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs @@ -10,12 +10,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Fetching { public sealed class FetchResourceTests - : IClassFixture, ReadWriteDbContext>> + : IClassFixture, ReadWriteDbContext>> { - private readonly IntegrationTestContext, ReadWriteDbContext> _testContext; + private readonly ExampleIntegrationTestContext, ReadWriteDbContext> _testContext; private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); - public FetchResourceTests(IntegrationTestContext, ReadWriteDbContext> testContext) + public FetchResourceTests(ExampleIntegrationTestContext, ReadWriteDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs index 701322743e..c8ac2c66ff 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -11,12 +11,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Updating.Relationships { public sealed class AddToToManyRelationshipTests - : IClassFixture, ReadWriteDbContext>> + : IClassFixture, ReadWriteDbContext>> { - private readonly IntegrationTestContext, ReadWriteDbContext> _testContext; + private readonly ExampleIntegrationTestContext, ReadWriteDbContext> _testContext; private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); - public AddToToManyRelationshipTests(IntegrationTestContext, ReadWriteDbContext> testContext) + public AddToToManyRelationshipTests(ExampleIntegrationTestContext, ReadWriteDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index e284e0edfc..7dbadd7717 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -11,12 +11,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Updating.Relationships { public sealed class RemoveFromToManyRelationshipTests - : IClassFixture, ReadWriteDbContext>> + : IClassFixture, ReadWriteDbContext>> { - private readonly IntegrationTestContext, ReadWriteDbContext> _testContext; + private readonly ExampleIntegrationTestContext, ReadWriteDbContext> _testContext; private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); - public RemoveFromToManyRelationshipTests(IntegrationTestContext, ReadWriteDbContext> testContext) + public RemoveFromToManyRelationshipTests(ExampleIntegrationTestContext, ReadWriteDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs index e0f901c4d8..bfdd768bd6 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -11,12 +11,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Updating.Relationships { public sealed class ReplaceToManyRelationshipTests - : IClassFixture, ReadWriteDbContext>> + : IClassFixture, ReadWriteDbContext>> { - private readonly IntegrationTestContext, ReadWriteDbContext> _testContext; + private readonly ExampleIntegrationTestContext, ReadWriteDbContext> _testContext; private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); - public ReplaceToManyRelationshipTests(IntegrationTestContext, ReadWriteDbContext> testContext) + public ReplaceToManyRelationshipTests(ExampleIntegrationTestContext, ReadWriteDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs index fd9eeadad9..e0f202b068 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs @@ -10,12 +10,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Updating.Relationships { public sealed class UpdateToOneRelationshipTests - : IClassFixture, ReadWriteDbContext>> + : IClassFixture, ReadWriteDbContext>> { - private readonly IntegrationTestContext, ReadWriteDbContext> _testContext; + private readonly ExampleIntegrationTestContext, ReadWriteDbContext> _testContext; private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); - public UpdateToOneRelationshipTests(IntegrationTestContext, ReadWriteDbContext> testContext) + public UpdateToOneRelationshipTests(ExampleIntegrationTestContext, ReadWriteDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs index c4d083d534..163d6b9d5e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -11,12 +11,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Updating.Resources { public sealed class ReplaceToManyRelationshipTests - : IClassFixture, ReadWriteDbContext>> + : IClassFixture, ReadWriteDbContext>> { - private readonly IntegrationTestContext, ReadWriteDbContext> _testContext; + private readonly ExampleIntegrationTestContext, ReadWriteDbContext> _testContext; private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); - public ReplaceToManyRelationshipTests(IntegrationTestContext, ReadWriteDbContext> testContext) + public ReplaceToManyRelationshipTests(ExampleIntegrationTestContext, ReadWriteDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs index b44199bf14..bc9f15e423 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs @@ -13,12 +13,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Updating.Resources { public sealed class UpdateResourceTests - : IClassFixture, ReadWriteDbContext>> + : IClassFixture, ReadWriteDbContext>> { - private readonly IntegrationTestContext, ReadWriteDbContext> _testContext; + private readonly ExampleIntegrationTestContext, ReadWriteDbContext> _testContext; private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); - public UpdateResourceTests(IntegrationTestContext, ReadWriteDbContext> testContext) + public UpdateResourceTests(ExampleIntegrationTestContext, ReadWriteDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs index c489306d62..7c4eb34229 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -10,12 +10,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ReadWrite.Updating.Resources { public sealed class UpdateToOneRelationshipTests - : IClassFixture, ReadWriteDbContext>> + : IClassFixture, ReadWriteDbContext>> { - private readonly IntegrationTestContext, ReadWriteDbContext> _testContext; + private readonly ExampleIntegrationTestContext, ReadWriteDbContext> _testContext; private readonly ReadWriteFakers _fakers = new ReadWriteFakers(); - public UpdateToOneRelationshipTests(IntegrationTestContext, ReadWriteDbContext> testContext) + public UpdateToOneRelationshipTests(ExampleIntegrationTestContext, ReadWriteDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs index df033d8c5f..0f8d4fdda9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs @@ -9,13 +9,13 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RequiredRelationships { - public sealed class DefaultBehaviorTests : IClassFixture, DefaultBehaviorDbContext>> + public sealed class DefaultBehaviorTests : IClassFixture, DefaultBehaviorDbContext>> { - private readonly IntegrationTestContext, DefaultBehaviorDbContext> _testContext; + private readonly ExampleIntegrationTestContext, DefaultBehaviorDbContext> _testContext; private readonly DefaultBehaviorFakers _fakers = new DefaultBehaviorFakers(); - public DefaultBehaviorTests(IntegrationTestContext, DefaultBehaviorDbContext> testContext) + public DefaultBehaviorTests(ExampleIntegrationTestContext, DefaultBehaviorDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs index dcec12f073..e381cdece2 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs @@ -13,12 +13,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceConstructorInjection { public sealed class ResourceInjectionTests - : IClassFixture, InjectionDbContext>> + : IClassFixture, InjectionDbContext>> { - private readonly IntegrationTestContext, InjectionDbContext> _testContext; + private readonly ExampleIntegrationTestContext, InjectionDbContext> _testContext; private readonly InjectionFakers _fakers; - public ResourceInjectionTests(IntegrationTestContext, InjectionDbContext> testContext) + public ResourceInjectionTests(ExampleIntegrationTestContext, InjectionDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs index d75a3a3774..41e010ad5a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs @@ -12,11 +12,11 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions { - public sealed class ResourceDefinitionQueryCallbackTests : IClassFixture, CallableDbContext>> + public sealed class ResourceDefinitionQueryCallbackTests : IClassFixture, CallableDbContext>> { - private readonly IntegrationTestContext, CallableDbContext> _testContext; + private readonly ExampleIntegrationTestContext, CallableDbContext> _testContext; - public ResourceDefinitionQueryCallbackTests(IntegrationTestContext, CallableDbContext> testContext) + public ResourceDefinitionQueryCallbackTests(ExampleIntegrationTestContext, CallableDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs index d08615ad66..96b042c7e4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs @@ -22,12 +22,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceHooks { public sealed class ResourceHookTests - : IClassFixture, AppDbContext>> + : IClassFixture, AppDbContext>> { - private readonly IntegrationTestContext, AppDbContext> _testContext; + private readonly ExampleIntegrationTestContext, AppDbContext> _testContext; private readonly ExampleFakers _fakers; - public ResourceHookTests(IntegrationTestContext, AppDbContext> testContext) + public ResourceHookTests(ExampleIntegrationTestContext, AppDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHooksStartup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHooksStartup.cs index c41f798a00..af60191944 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHooksStartup.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHooksStartup.cs @@ -21,7 +21,7 @@ public override void ConfigureServices(IServiceCollection services) base.ConfigureServices(services); - services.AddControllersFromTestProject(); + services.AddControllersFromExampleProject(); services.AddClientSerialization(); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs index 3f0fa157f2..0ce3721116 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs @@ -10,11 +10,11 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceInheritance { - public sealed class InheritanceTests : IClassFixture, InheritanceDbContext>> + public sealed class InheritanceTests : IClassFixture, InheritanceDbContext>> { - private readonly IntegrationTestContext, InheritanceDbContext> _testContext; + private readonly ExampleIntegrationTestContext, InheritanceDbContext> _testContext; - public InheritanceTests(IntegrationTestContext, InheritanceDbContext> testContext) + public InheritanceTests(ExampleIntegrationTestContext, InheritanceDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs index f1dadcece0..6f73c145da 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs @@ -10,12 +10,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers { public sealed class DisableQueryStringTests - : IClassFixture, RestrictionDbContext>> + : IClassFixture, RestrictionDbContext>> { - private readonly IntegrationTestContext, RestrictionDbContext> _testContext; + private readonly ExampleIntegrationTestContext, RestrictionDbContext> _testContext; private readonly RestrictionFakers _fakers = new RestrictionFakers(); - public DisableQueryStringTests(IntegrationTestContext, RestrictionDbContext> testContext) + public DisableQueryStringTests(ExampleIntegrationTestContext, RestrictionDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/HttpReadOnlyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/HttpReadOnlyTests.cs index 0ce068bf3d..0895e7d8b4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/HttpReadOnlyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/HttpReadOnlyTests.cs @@ -8,12 +8,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers { public sealed class HttpReadOnlyTests - : IClassFixture, RestrictionDbContext>> + : IClassFixture, RestrictionDbContext>> { - private readonly IntegrationTestContext, RestrictionDbContext> _testContext; + private readonly ExampleIntegrationTestContext, RestrictionDbContext> _testContext; private readonly RestrictionFakers _fakers = new RestrictionFakers(); - public HttpReadOnlyTests(IntegrationTestContext, RestrictionDbContext> testContext) + public HttpReadOnlyTests(ExampleIntegrationTestContext, RestrictionDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpDeleteTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpDeleteTests.cs index 3fecae6275..699d1fc7df 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpDeleteTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpDeleteTests.cs @@ -8,12 +8,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers { public sealed class NoHttpDeleteTests - : IClassFixture, RestrictionDbContext>> + : IClassFixture, RestrictionDbContext>> { - private readonly IntegrationTestContext, RestrictionDbContext> _testContext; + private readonly ExampleIntegrationTestContext, RestrictionDbContext> _testContext; private readonly RestrictionFakers _fakers = new RestrictionFakers(); - public NoHttpDeleteTests(IntegrationTestContext, RestrictionDbContext> testContext) + public NoHttpDeleteTests(ExampleIntegrationTestContext, RestrictionDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPatchTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPatchTests.cs index d972d2f429..9b2d7a3ea3 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPatchTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPatchTests.cs @@ -8,12 +8,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers { public sealed class NoHttpPatchTests - : IClassFixture, RestrictionDbContext>> + : IClassFixture, RestrictionDbContext>> { - private readonly IntegrationTestContext, RestrictionDbContext> _testContext; + private readonly ExampleIntegrationTestContext, RestrictionDbContext> _testContext; private readonly RestrictionFakers _fakers = new RestrictionFakers(); - public NoHttpPatchTests(IntegrationTestContext, RestrictionDbContext> testContext) + public NoHttpPatchTests(ExampleIntegrationTestContext, RestrictionDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPostTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPostTests.cs index eefdda4aa6..837a0a1eeb 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPostTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPostTests.cs @@ -8,12 +8,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers { public sealed class NoHttpPostTests - : IClassFixture, RestrictionDbContext>> + : IClassFixture, RestrictionDbContext>> { - private readonly IntegrationTestContext, RestrictionDbContext> _testContext; + private readonly ExampleIntegrationTestContext, RestrictionDbContext> _testContext; private readonly RestrictionFakers _fakers = new RestrictionFakers(); - public NoHttpPostTests(IntegrationTestContext, RestrictionDbContext> testContext) + public NoHttpPostTests(ExampleIntegrationTestContext, RestrictionDbContext> testContext) { _testContext = testContext; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationTests.cs index e90f0e1b50..b57aebe571 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationTests.cs @@ -15,12 +15,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Serialization { public sealed class SerializationTests - : IClassFixture, SerializationDbContext>> + : IClassFixture, SerializationDbContext>> { - private readonly IntegrationTestContext, SerializationDbContext> _testContext; + private readonly ExampleIntegrationTestContext, SerializationDbContext> _testContext; private readonly SerializationFakers _fakers = new SerializationFakers(); - public SerializationTests(IntegrationTestContext, SerializationDbContext> testContext) + public SerializationTests(ExampleIntegrationTestContext, SerializationDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs index 874b1626fd..af26d97e67 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs @@ -11,11 +11,11 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion { - public sealed class SoftDeletionTests : IClassFixture, SoftDeletionDbContext>> + public sealed class SoftDeletionTests : IClassFixture, SoftDeletionDbContext>> { - private readonly IntegrationTestContext, SoftDeletionDbContext> _testContext; + private readonly ExampleIntegrationTestContext, SoftDeletionDbContext> _testContext; - public SoftDeletionTests(IntegrationTestContext, SoftDeletionDbContext> testContext) + public SoftDeletionTests(ExampleIntegrationTestContext, SoftDeletionDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs index b0d60fed21..79b8b21629 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs @@ -13,12 +13,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Sorting { - public sealed class SortTests : IClassFixture> + public sealed class SortTests : IClassFixture> { - private readonly IntegrationTestContext _testContext; + private readonly ExampleIntegrationTestContext _testContext; private readonly ExampleFakers _fakers; - public SortTests(IntegrationTestContext testContext) + public SortTests(ExampleIntegrationTestContext testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs index 31ecebed2a..37db55b381 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs @@ -16,12 +16,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SparseFieldSets { - public sealed class SparseFieldSetTests : IClassFixture> + public sealed class SparseFieldSetTests : IClassFixture> { - private readonly IntegrationTestContext _testContext; + private readonly ExampleIntegrationTestContext _testContext; private readonly ExampleFakers _fakers; - public SparseFieldSetTests(IntegrationTestContext testContext) + public SparseFieldSetTests(ExampleIntegrationTestContext testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs index 304d18b253..412a996f57 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs @@ -13,12 +13,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ZeroKeys { public sealed class EmptyGuidAsKeyTests - : IClassFixture, ZeroKeyDbContext>> + : IClassFixture, ZeroKeyDbContext>> { - private readonly IntegrationTestContext, ZeroKeyDbContext> _testContext; + private readonly ExampleIntegrationTestContext, ZeroKeyDbContext> _testContext; private readonly ZeroKeyFakers _fakers = new ZeroKeyFakers(); - public EmptyGuidAsKeyTests(IntegrationTestContext, ZeroKeyDbContext> testContext) + public EmptyGuidAsKeyTests(ExampleIntegrationTestContext, ZeroKeyDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs index fa58257c4a..8d13766ab9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs @@ -12,12 +12,12 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ZeroKeys { public sealed class ZeroAsKeyTests - : IClassFixture, ZeroKeyDbContext>> + : IClassFixture, ZeroKeyDbContext>> { - private readonly IntegrationTestContext, ZeroKeyDbContext> _testContext; + private readonly ExampleIntegrationTestContext, ZeroKeyDbContext> _testContext; private readonly ZeroKeyFakers _fakers = new ZeroKeyFakers(); - public ZeroAsKeyTests(IntegrationTestContext, ZeroKeyDbContext> testContext) + public ZeroAsKeyTests(ExampleIntegrationTestContext, ZeroKeyDbContext> testContext) { _testContext = testContext; diff --git a/test/JsonApiDotNetCoreExampleTests/ServiceCollectionExtensions.cs b/test/JsonApiDotNetCoreExampleTests/ServiceCollectionExtensions.cs index 93321b9897..e8eb3fa54e 100644 --- a/test/JsonApiDotNetCoreExampleTests/ServiceCollectionExtensions.cs +++ b/test/JsonApiDotNetCoreExampleTests/ServiceCollectionExtensions.cs @@ -6,7 +6,7 @@ namespace JsonApiDotNetCoreExampleTests { public static class ServiceCollectionExtensions { - public static void AddControllersFromTestProject(this IServiceCollection services) + public static void AddControllersFromExampleProject(this IServiceCollection services) { var part = new AssemblyPart(typeof(EmptyStartup).Assembly); services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); diff --git a/test/TestBuildingBlocks/BaseIntegrationTestContext.cs b/test/TestBuildingBlocks/BaseIntegrationTestContext.cs index b0d61c1e59..b7f7369820 100644 --- a/test/TestBuildingBlocks/BaseIntegrationTestContext.cs +++ b/test/TestBuildingBlocks/BaseIntegrationTestContext.cs @@ -15,7 +15,7 @@ namespace TestBuildingBlocks { /// - /// A test context that creates a new database and server instance before running tests and cleans up afterwards. + /// Base class for a test context that creates a new database and server instance before running tests and cleans up afterwards. /// You can either use this as a fixture on your tests class (init/cleanup runs once before/after all tests) or /// have your tests class inherit from it (init/cleanup runs once before/after each test). See /// for details on shared context usage. From d94235dc2e71f2792cad805a0448e73d4606cbc5 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 4 Feb 2021 16:03:32 +0100 Subject: [PATCH 096/123] Adjusted test names for ModelStateValidationTests --- .../ModelStateValidationTests.cs | 36 +++++++++---------- .../NoModelStateValidationTests.cs | 4 +-- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs index 0d8176ff36..50a489f9d9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs @@ -19,7 +19,7 @@ public ModelStateValidationTests(ExampleIntegrationTestContext } [Fact] - public async Task When_posting_annotated_to_many_relationship_it_must_succeed() + public async Task Can_add_to_annotated_ToMany_relationship() { // Arrange var directory = new SystemDirectory @@ -320,7 +320,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_patching_resource_with_omitted_required_attribute_value_it_must_succeed() + public async Task Can_update_resource_with_omitted_required_attribute_value() { // Arrange var directory = new SystemDirectory @@ -360,7 +360,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_patching_resource_with_null_for_required_attribute_value_it_must_fail() + public async Task Cannot_update_resource_with_null_for_required_attribute_value() { // Arrange var directory = new SystemDirectory @@ -404,7 +404,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_patching_resource_with_invalid_attribute_value_it_must_fail() + public async Task Cannot_update_resource_with_invalid_attribute_value() { // Arrange var directory = new SystemDirectory @@ -448,7 +448,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_patching_resource_with_invalid_ID_it_must_fail() + public async Task Cannot_update_resource_with_invalid_ID() { // Arrange var directory = new SystemDirectory @@ -512,7 +512,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_patching_resource_with_valid_attribute_value_it_must_succeed() + public async Task Can_update_resource_with_valid_attribute_value() { // Arrange var directory = new SystemDirectory @@ -552,7 +552,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_patching_resource_with_annotated_relationships_it_must_succeed() + public async Task Can_update_resource_with_annotated_relationships() { // Arrange var directory = new SystemDirectory @@ -664,7 +664,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_patching_resource_with_multiple_self_references_it_must_succeed() + public async Task Can_update_resource_with_multiple_self_references() { // Arrange var directory = new SystemDirectory @@ -723,7 +723,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_patching_resource_with_collection_of_self_references_it_must_succeed() + public async Task Can_update_resource_with_collection_of_self_references() { // Arrange var directory = new SystemDirectory @@ -777,7 +777,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_patching_annotated_ToOne_relationship_it_must_succeed() + public async Task Can_replace_annotated_ToOne_relationship() { // Arrange var directory = new SystemDirectory @@ -824,7 +824,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_patching_annotated_ToMany_relationship_it_must_succeed() + public async Task Can_replace_annotated_ToMany_relationship() { // Arrange var directory = new SystemDirectory @@ -881,7 +881,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_deleting_annotated_to_many_relationship_it_must_succeed() + public async Task Can_remove_from_annotated_ToMany_relationship() { // Arrange var directory = new SystemDirectory diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs index 0923df55e1..ca5d155cb6 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs @@ -17,7 +17,7 @@ public NoModelStateValidationTests(ExampleIntegrationTestContext Date: Thu, 4 Feb 2021 16:17:11 +0100 Subject: [PATCH 097/123] Adjusted test names for pagination --- .../PaginationWithoutTotalCountTests.cs | 12 ++++++------ .../Pagination/RangeValidationTests.cs | 14 +++++++------- .../RangeValidationWithMaximumTests.cs | 14 +++++++------- test/MultiDbContextTests/ResourceTests.cs | 18 +++++------------- 4 files changed, 25 insertions(+), 33 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithoutTotalCountTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithoutTotalCountTests.cs index 2f291af433..2892ec63e7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithoutTotalCountTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithoutTotalCountTests.cs @@ -33,7 +33,7 @@ public PaginationWithoutTotalCountTests(ExampleIntegrationTestContext(); @@ -56,7 +56,7 @@ public async Task When_page_size_is_unconstrained_it_should_not_render_paginatio } [Fact] - public async Task When_page_size_is_specified_in_query_string_with_no_data_it_should_render_pagination_links() + public async Task Renders_pagination_links_when_page_size_is_specified_in_query_string_with_no_data() { // Arrange var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); @@ -85,7 +85,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_page_number_is_specified_in_query_string_with_no_data_it_should_render_pagination_links() + public async Task Renders_pagination_links_when_page_number_is_specified_in_query_string_with_no_data() { // Arrange await _testContext.RunOnDatabaseAsync(async dbContext => @@ -111,7 +111,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_page_number_is_specified_in_query_string_with_partially_filled_page_it_should_render_pagination_links() + public async Task Renders_pagination_links_when_page_number_is_specified_in_query_string_with_partially_filled_page() { // Arrange var articles = _fakers.Article.Generate(12); @@ -143,7 +143,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_page_number_is_specified_in_query_string_with_full_page_it_should_render_pagination_links() + public async Task Renders_pagination_links_when_page_number_is_specified_in_query_string_with_full_page() { // Arrange var articles = _fakers.Article.Generate(_defaultPageSize * 3); @@ -175,7 +175,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_page_number_is_specified_in_query_string_with_full_page_on_secondary_endpoint_it_should_render_pagination_links() + public async Task Renders_pagination_links_when_page_number_is_specified_in_query_string_with_full_page_on_secondary_endpoint() { // Arrange var author = new Author diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationTests.cs index e9974a3d29..4382215942 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationTests.cs @@ -32,7 +32,7 @@ public RangeValidationTests(ExampleIntegrationTestContext } [Fact] - public async Task When_page_number_is_negative_it_must_fail() + public async Task Cannot_use_negative_page_number() { // Arrange var route = "/api/v1/todoItems?page[number]=-1"; @@ -51,7 +51,7 @@ public async Task When_page_number_is_negative_it_must_fail() } [Fact] - public async Task When_page_number_is_zero_it_must_fail() + public async Task Cannot_use_zero_page_number() { // Arrange var route = "/api/v1/todoItems?page[number]=0"; @@ -70,7 +70,7 @@ public async Task When_page_number_is_zero_it_must_fail() } [Fact] - public async Task When_page_number_is_positive_it_must_succeed() + public async Task Can_use_positive_page_number() { // Arrange var route = "/api/v1/todoItems?page[number]=20"; @@ -83,7 +83,7 @@ public async Task When_page_number_is_positive_it_must_succeed() } [Fact] - public async Task When_page_number_is_too_high_it_must_return_empty_set_of_resources() + public async Task Returns_empty_set_of_resources_when_page_number_is_too_high() { // Arrange var todoItems = _fakers.TodoItem.Generate(3); @@ -108,7 +108,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task When_page_size_is_negative_it_must_fail() + public async Task Cannot_use_negative_page_size() { // Arrange var route = "/api/v1/todoItems?page[size]=-1"; @@ -127,7 +127,7 @@ public async Task When_page_size_is_negative_it_must_fail() } [Fact] - public async Task When_page_size_is_zero_it_must_succeed() + public async Task Can_use_zero_page_size() { // Arrange var route = "/api/v1/todoItems?page[size]=0"; @@ -140,7 +140,7 @@ public async Task When_page_size_is_zero_it_must_succeed() } [Fact] - public async Task When_page_size_is_positive_it_must_succeed() + public async Task Can_use_positive_page_size() { // Arrange var route = "/api/v1/todoItems?page[size]=50"; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationWithMaximumTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationWithMaximumTests.cs index 62f773be25..6fe73f6df6 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationWithMaximumTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationWithMaximumTests.cs @@ -29,7 +29,7 @@ public RangeValidationWithMaximumTests(ExampleIntegrationTestContext factory) } [Fact] - public async Task Can_Get_ResourceAs() + public async Task Can_get_ResourceAs() { // Arrange var client = _factory.CreateClient(); @@ -31,7 +32,7 @@ public async Task Can_Get_ResourceAs() var response = await client.SendAsync(request); // Assert - AssertStatusCode(HttpStatusCode.OK, response); + response.Should().HaveStatusCode(HttpStatusCode.OK); string responseBody = await response.Content.ReadAsStringAsync(); var document = JsonConvert.DeserializeObject(responseBody); @@ -41,7 +42,7 @@ public async Task Can_Get_ResourceAs() } [Fact] - public async Task Can_Get_ResourceBs() + public async Task Can_get_ResourceBs() { // Arrange var client = _factory.CreateClient(); @@ -52,7 +53,7 @@ public async Task Can_Get_ResourceBs() var response = await client.SendAsync(request); // Assert - AssertStatusCode(HttpStatusCode.OK, response); + response.Should().HaveStatusCode(HttpStatusCode.OK); string responseBody = await response.Content.ReadAsStringAsync(); var document = JsonConvert.DeserializeObject(responseBody); @@ -60,14 +61,5 @@ public async Task Can_Get_ResourceBs() document.ManyData.Should().HaveCount(1); document.ManyData[0].Attributes["nameB"].Should().Be("SampleB"); } - - private static void AssertStatusCode(HttpStatusCode expected, HttpResponseMessage response) - { - if (expected != response.StatusCode) - { - var responseBody = response.Content.ReadAsStringAsync().Result; - Assert.True(expected == response.StatusCode, $"Got {response.StatusCode} status code instead of {expected}. Response body: {responseBody}"); - } - } } } From cb983b9a6cc8973257822d23d6774f1dac5810e6 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 4 Feb 2021 16:42:17 +0100 Subject: [PATCH 098/123] Adjusted test names for meta --- .../Models/TodoItem.cs | 7 ------ .../Meta/ResourceMetaTests.cs | 4 ++-- .../Meta/ResponseMetaTests.cs | 22 +---------------- .../IntegrationTests/Meta/TestResponseMeta.cs | 24 +++++++++++++++++++ .../Meta/TopLevelCountTests.cs | 14 +++++++---- 5 files changed, 37 insertions(+), 34 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TestResponseMeta.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index 64afada036..abef810db1 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs @@ -15,13 +15,6 @@ public class TodoItem : Identifiable, IIsLockable [Attr] public long Ordinal { get; set; } - [Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowCreate)] - public string AlwaysChangingValue - { - get => Guid.NewGuid().ToString(); - set { } - } - [Attr] public DateTime CreatedDate { get; set; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs index 5fcd446864..6236b7fdef 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs @@ -21,7 +21,7 @@ public ResourceMetaTests(ExampleIntegrationTestContext te } [Fact] - public async Task ResourceDefinition_That_Implements_GetMeta_Contains_Resource_Meta() + public async Task Returns_resource_meta_from_ResourceDefinition() { // Arrange var todoItems = new[] @@ -54,7 +54,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task ResourceDefinition_That_Implements_GetMeta_Contains_Include_Meta() + public async Task Returns_resource_meta_from_ResourceDefinition_in_included_resources() { // Arrange var person = new Person diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs index f90f29223c..dd0464d5d6 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Net; using System.Threading.Tasks; using FluentAssertions; @@ -31,7 +30,7 @@ public ResponseMetaTests(ExampleIntegrationTestContext te } [Fact] - public async Task Registered_IResponseMeta_Adds_TopLevel_Meta() + public async Task Returns_top_level_meta() { // Arrange await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); }); @@ -63,23 +62,4 @@ public async Task Registered_IResponseMeta_Adds_TopLevel_Meta() }"); } } - - public sealed class TestResponseMeta : 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/Meta/TestResponseMeta.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TestResponseMeta.cs new file mode 100644 index 0000000000..913986f316 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TestResponseMeta.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Serialization; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta +{ + public sealed class TestResponseMeta : 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/Meta/TopLevelCountTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs index 3b19ebb9fc..4b2823f6cc 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; @@ -20,12 +21,17 @@ public TopLevelCountTests(ExampleIntegrationTestContext t { _testContext = testContext; + testContext.ConfigureServicesAfterStartup(services => + { + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); + var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); options.IncludeTotalResourceCount = true; } [Fact] - public async Task Total_Resource_Count_Included_For_Collection() + public async Task Renders_resource_count_for_collection() { // Arrange var todoItem = new TodoItem(); @@ -51,7 +57,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Total_Resource_Count_Included_For_Empty_Collection() + public async Task Renders_resource_count_for_empty_collection() { // Arrange await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); }); @@ -69,7 +75,7 @@ public async Task Total_Resource_Count_Included_For_Empty_Collection() } [Fact] - public async Task Total_Resource_Count_Excluded_From_POST_Response() + public async Task Hides_resource_count_in_create_resource_response() { // Arrange var requestBody = new @@ -96,7 +102,7 @@ public async Task Total_Resource_Count_Excluded_From_POST_Response() } [Fact] - public async Task Total_Resource_Count_Excluded_From_PATCH_Response() + public async Task Hides_resource_count_in_update_resource_response() { // Arrange var todoItem = new TodoItem(); From 5ad5c76cc89b364b0dd0b7e24a47d8c14e5ecb87 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 4 Feb 2021 17:06:37 +0100 Subject: [PATCH 099/123] Fixed broken tests; simpler bootstrap setup --- test/MultiDbContextTests/ResourceTests.cs | 35 +++--- test/NoEntityFrameworkTests/WorkItemTests.cs | 38 ++++-- .../BaseIntegrationTestContext.cs | 107 +--------------- test/TestBuildingBlocks/IntegrationTest.cs | 114 ++++++++++++++++++ .../RemoteIntegrationTestContext.cs | 18 --- 5 files changed, 162 insertions(+), 150 deletions(-) create mode 100644 test/TestBuildingBlocks/IntegrationTest.cs delete mode 100644 test/TestBuildingBlocks/RemoteIntegrationTestContext.cs diff --git a/test/MultiDbContextTests/ResourceTests.cs b/test/MultiDbContextTests/ResourceTests.cs index f02e4d1e1c..91caa3d293 100644 --- a/test/MultiDbContextTests/ResourceTests.cs +++ b/test/MultiDbContextTests/ResourceTests.cs @@ -11,7 +11,7 @@ namespace MultiDbContextTests { - public sealed class ResourceTests : IClassFixture> + public sealed class ResourceTests : IntegrationTest, IClassFixture> { private readonly WebApplicationFactory _factory; @@ -24,42 +24,37 @@ public ResourceTests(WebApplicationFactory factory) public async Task Can_get_ResourceAs() { // Arrange - var client = _factory.CreateClient(); - - var request = new HttpRequestMessage(HttpMethod.Get, "/resourceAs"); + var route = "/resourceAs"; // Act - var response = await client.SendAsync(request); + var (httpResponse, responseDocument) = await ExecuteGetAsync(route); // Assert - response.Should().HaveStatusCode(HttpStatusCode.OK); - - string responseBody = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(responseBody); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - document.ManyData.Should().HaveCount(1); - document.ManyData[0].Attributes["nameA"].Should().Be("SampleA"); + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["nameA"].Should().Be("SampleA"); } [Fact] public async Task Can_get_ResourceBs() { // Arrange - var client = _factory.CreateClient(); - - var request = new HttpRequestMessage(HttpMethod.Get, "/resourceBs"); + var route = "/resourceBs"; // Act - var response = await client.SendAsync(request); + var (httpResponse, responseDocument) = await ExecuteGetAsync(route); // Assert - response.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - string responseBody = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(responseBody); + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Attributes["nameB"].Should().Be("SampleB"); + } - document.ManyData.Should().HaveCount(1); - document.ManyData[0].Attributes["nameB"].Should().Be("SampleB"); + protected override HttpClient CreateClient() + { + return _factory.CreateClient(); } } } diff --git a/test/NoEntityFrameworkTests/WorkItemTests.cs b/test/NoEntityFrameworkTests/WorkItemTests.cs index f616691af3..5b1ce11a44 100644 --- a/test/NoEntityFrameworkTests/WorkItemTests.cs +++ b/test/NoEntityFrameworkTests/WorkItemTests.cs @@ -1,8 +1,11 @@ using System; using System.Net; +using System.Net.Http; using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; using NoEntityFrameworkExample; using NoEntityFrameworkExample.Data; using NoEntityFrameworkExample.Models; @@ -11,20 +14,20 @@ namespace NoEntityFrameworkTests { - public sealed class WorkItemTests : IClassFixture> + public sealed class WorkItemTests : IntegrationTest, IClassFixture> { - private readonly RemoteIntegrationTestContext _testContext; + private readonly WebApplicationFactory _factory; - public WorkItemTests(RemoteIntegrationTestContext testContext) + public WorkItemTests(WebApplicationFactory factory) { - _testContext = testContext; + _factory = factory; } [Fact] public async Task Can_get_WorkItems() { // Arrange - await _testContext.RunOnDatabaseAsync(async dbContext => + await RunOnDatabaseAsync(async dbContext => { dbContext.WorkItems.Add(new WorkItem()); await dbContext.SaveChangesAsync(); @@ -33,7 +36,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/api/v1/workItems"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + var (httpResponse, responseDocument) = await ExecuteGetAsync(route); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); @@ -47,7 +50,7 @@ public async Task Can_get_WorkItem_by_ID() // Arrange var workItem = new WorkItem(); - await _testContext.RunOnDatabaseAsync(async dbContext => + await RunOnDatabaseAsync(async dbContext => { dbContext.WorkItems.Add(workItem); await dbContext.SaveChangesAsync(); @@ -56,7 +59,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/api/v1/workItems/" + workItem.StringId; // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + var (httpResponse, responseDocument) = await ExecuteGetAsync(route); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); @@ -87,7 +90,7 @@ public async Task Can_create_WorkItem() var route = "/api/v1/workItems/"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, responseDocument) = await ExecutePostAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); @@ -102,7 +105,7 @@ public async Task Can_delete_WorkItem() // Arrange var workItem = new WorkItem(); - await _testContext.RunOnDatabaseAsync(async dbContext => + await RunOnDatabaseAsync(async dbContext => { dbContext.WorkItems.Add(workItem); await dbContext.SaveChangesAsync(); @@ -111,12 +114,25 @@ await _testContext.RunOnDatabaseAsync(async dbContext => var route = "/api/v1/workItems/" + workItem.StringId; // Act - var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + var (httpResponse, responseDocument) = await ExecuteDeleteAsync(route); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); responseDocument.Should().BeEmpty(); } + + protected override HttpClient CreateClient() + { + return _factory.CreateClient(); + } + + private async Task RunOnDatabaseAsync(Func asyncAction) + { + using IServiceScope scope = _factory.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + await asyncAction(dbContext); + } } } diff --git a/test/TestBuildingBlocks/BaseIntegrationTestContext.cs b/test/TestBuildingBlocks/BaseIntegrationTestContext.cs index b7f7369820..d06439b823 100644 --- a/test/TestBuildingBlocks/BaseIntegrationTestContext.cs +++ b/test/TestBuildingBlocks/BaseIntegrationTestContext.cs @@ -1,16 +1,12 @@ using System; -using System.Collections.Generic; using System.Net.Http; -using System.Net.Http.Headers; using System.Threading.Tasks; -using JsonApiDotNetCore.Middleware; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; namespace TestBuildingBlocks { @@ -23,7 +19,7 @@ namespace TestBuildingBlocks /// The server Startup class, which can be defined in the test project. /// The base class for , which MUST be defined in the API project. /// The EF Core database context, which can be defined in the test project. - public abstract class BaseIntegrationTestContext : IDisposable + public abstract class BaseIntegrationTestContext : IntegrationTest, IDisposable where TStartup : class where TRemoteStartup : class where TDbContext : DbContext @@ -35,6 +31,11 @@ public abstract class BaseIntegrationTestContext Factory => _lazyFactory.Value; + protected override HttpClient CreateClient() + { + return Factory.CreateClient(); + } + protected BaseIntegrationTestContext() { _lazyFactory = new Lazy>(CreateFactory); @@ -103,102 +104,6 @@ public async Task RunOnDatabaseAsync(Func asyncAction) await asyncAction(dbContext); } - public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> - ExecuteGetAsync(string requestUrl, - IEnumerable acceptHeaders = null) - { - return await ExecuteRequestAsync(HttpMethod.Get, requestUrl, null, null, acceptHeaders); - } - - public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> - ExecutePostAsync(string requestUrl, object requestBody, - string contentType = HeaderConstants.MediaType, - 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, - IEnumerable acceptHeaders = null) - { - return await ExecuteRequestAsync(HttpMethod.Patch, requestUrl, requestBody, contentType, - acceptHeaders); - } - - public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> - ExecuteDeleteAsync(string requestUrl, object requestBody = null, - string contentType = HeaderConstants.MediaType, - IEnumerable acceptHeaders = null) - { - return await ExecuteRequestAsync(HttpMethod.Delete, requestUrl, requestBody, contentType, - acceptHeaders); - } - - private async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> - ExecuteRequestAsync(HttpMethod method, string requestUrl, object requestBody, - string contentType, IEnumerable acceptHeaders) - { - var request = new HttpRequestMessage(method, requestUrl); - string requestText = SerializeRequest(requestBody); - - if (!string.IsNullOrEmpty(requestText)) - { - request.Content = new StringContent(requestText); - - if (contentType != null) - { - request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); - } - } - - using HttpClient client = Factory.CreateClient(); - - if (acceptHeaders != null) - { - foreach (var acceptHeader in acceptHeaders) - { - client.DefaultRequestHeaders.Accept.Add(acceptHeader); - } - } - - HttpResponseMessage responseMessage = await client.SendAsync(request); - - string responseText = await responseMessage.Content.ReadAsStringAsync(); - var responseDocument = DeserializeResponse(responseText); - - return (responseMessage, responseDocument); - } - - private string SerializeRequest(object requestBody) - { - return requestBody == null - ? null - : requestBody is string stringRequestBody - ? stringRequestBody - : JsonConvert.SerializeObject(requestBody); - } - - private TResponseDocument DeserializeResponse(string responseText) - { - if (typeof(TResponseDocument) == typeof(string)) - { - return (TResponseDocument)(object)responseText; - } - - try - { - return JsonConvert.DeserializeObject(responseText, - IntegrationTestConfiguration.DeserializationSettings); - } - catch (JsonException exception) - { - throw new FormatException($"Failed to deserialize response body to JSON:\n{responseText}", exception); - } - } - private sealed class IntegrationTestWebApplicationFactory : WebApplicationFactory { private Action _loggingConfiguration; diff --git a/test/TestBuildingBlocks/IntegrationTest.cs b/test/TestBuildingBlocks/IntegrationTest.cs new file mode 100644 index 0000000000..12329bd1c8 --- /dev/null +++ b/test/TestBuildingBlocks/IntegrationTest.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using JsonApiDotNetCore.Middleware; +using Newtonsoft.Json; + +namespace TestBuildingBlocks +{ + /// + /// A base class for tests that conveniently enables to execute HTTP requests against json:api endpoints. + /// + public abstract class IntegrationTest + { + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> + ExecuteGetAsync(string requestUrl, + IEnumerable acceptHeaders = null) + { + return await ExecuteRequestAsync(HttpMethod.Get, requestUrl, null, null, acceptHeaders); + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> + ExecutePostAsync(string requestUrl, object requestBody, + string contentType = HeaderConstants.MediaType, + 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, + IEnumerable acceptHeaders = null) + { + return await ExecuteRequestAsync(HttpMethod.Patch, requestUrl, requestBody, contentType, + acceptHeaders); + } + + public async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> + ExecuteDeleteAsync(string requestUrl, object requestBody = null, + string contentType = HeaderConstants.MediaType, + IEnumerable acceptHeaders = null) + { + return await ExecuteRequestAsync(HttpMethod.Delete, requestUrl, requestBody, contentType, + acceptHeaders); + } + + private async Task<(HttpResponseMessage httpResponse, TResponseDocument responseDocument)> + ExecuteRequestAsync(HttpMethod method, string requestUrl, object requestBody, + string contentType, IEnumerable acceptHeaders) + { + var request = new HttpRequestMessage(method, requestUrl); + string requestText = SerializeRequest(requestBody); + + if (!string.IsNullOrEmpty(requestText)) + { + request.Content = new StringContent(requestText); + + if (contentType != null) + { + request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); + } + } + + using HttpClient client = CreateClient(); + + if (acceptHeaders != null) + { + foreach (var acceptHeader in acceptHeaders) + { + client.DefaultRequestHeaders.Accept.Add(acceptHeader); + } + } + + HttpResponseMessage responseMessage = await client.SendAsync(request); + + string responseText = await responseMessage.Content.ReadAsStringAsync(); + var responseDocument = DeserializeResponse(responseText); + + return (responseMessage, responseDocument); + } + + private string SerializeRequest(object requestBody) + { + return requestBody == null + ? null + : requestBody is string stringRequestBody + ? stringRequestBody + : JsonConvert.SerializeObject(requestBody); + } + + protected abstract HttpClient CreateClient(); + + private TResponseDocument DeserializeResponse(string responseText) + { + if (typeof(TResponseDocument) == typeof(string)) + { + return (TResponseDocument)(object)responseText; + } + + try + { + return JsonConvert.DeserializeObject(responseText, + IntegrationTestConfiguration.DeserializationSettings); + } + catch (JsonException exception) + { + throw new FormatException($"Failed to deserialize response body to JSON:\n{responseText}", exception); + } + } + } +} diff --git a/test/TestBuildingBlocks/RemoteIntegrationTestContext.cs b/test/TestBuildingBlocks/RemoteIntegrationTestContext.cs deleted file mode 100644 index d9555737d9..0000000000 --- a/test/TestBuildingBlocks/RemoteIntegrationTestContext.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace TestBuildingBlocks -{ - /// - /// A test context that creates a new database and server instance before running tests and cleans up afterwards. - /// You can either use this as a fixture on your tests class (init/cleanup runs once before/after all tests) or - /// have your tests class inherit from it (init/cleanup runs once before/after each test). See - /// for details on shared context usage. - /// - /// The server Startup class, which MUST be defined in the API project. - /// The EF Core database context, which MUST be defined in the API project. - public class RemoteIntegrationTestContext : BaseIntegrationTestContext - where TStartup : class - where TDbContext : DbContext - { - } -} From 838a158f3161b22d837bb7219895536b1f1a2741 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 4 Feb 2021 17:10:41 +0100 Subject: [PATCH 100/123] Adjusted test names for hooks --- .../ResourceHooks/ResourceHookTests.cs | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs index 96b042c7e4..a24996c3bc 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs @@ -48,7 +48,7 @@ public ResourceHookTests(ExampleIntegrationTestContext Date: Thu, 4 Feb 2021 17:31:35 +0100 Subject: [PATCH 101/123] Enable concurrent testruns (makes running all tests 60% faster) --- .../JsonApiDotNetCoreExampleTests.csproj | 6 ------ test/JsonApiDotNetCoreExampleTests/xunit.runner.json | 5 ----- 2 files changed, 11 deletions(-) delete mode 100644 test/JsonApiDotNetCoreExampleTests/xunit.runner.json diff --git a/test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj b/test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj index 77e604e150..8bf9f701f4 100644 --- a/test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj +++ b/test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj @@ -3,12 +3,6 @@ $(NetCoreAppVersion) - - - PreserveNewest - - - diff --git a/test/JsonApiDotNetCoreExampleTests/xunit.runner.json b/test/JsonApiDotNetCoreExampleTests/xunit.runner.json deleted file mode 100644 index 8f5f10571b..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/xunit.runner.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "parallelizeAssembly": false, - "parallelizeTestCollections": false, - "maxParallelThreads": 1 -} From 64c5b4acabaa99a5d2b4f653dda0bba886375350 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 4 Feb 2021 17:37:43 +0100 Subject: [PATCH 102/123] Removed unused using --- test/MultiDbContextTests/ResourceTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/MultiDbContextTests/ResourceTests.cs b/test/MultiDbContextTests/ResourceTests.cs index 91caa3d293..eace446619 100644 --- a/test/MultiDbContextTests/ResourceTests.cs +++ b/test/MultiDbContextTests/ResourceTests.cs @@ -5,7 +5,6 @@ using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Mvc.Testing; using MultiDbContextExample; -using Newtonsoft.Json; using TestBuildingBlocks; using Xunit; From bcb73205c5a20563f6a21b8e4642eee7beeb4e7b Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 4 Feb 2021 18:20:09 +0100 Subject: [PATCH 103/123] Cleanup tests for hooks --- .../ResourceHooks/ResourceHookTests.cs | 395 ++++++------------ 1 file changed, 124 insertions(+), 271 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs index 93916091fc..506d13152d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs @@ -2,18 +2,16 @@ using System.Linq; using System.Linq.Expressions; using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Client.Internal; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Definitions; using JsonApiDotNetCoreExample.Models; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; using TestBuildingBlocks; @@ -54,36 +52,29 @@ public async Task Can_create_user_with_password() var user = _fakers.User.Generate(); var serializer = GetRequestSerializer(p => new {p.Password, p.UserName}); + string requestBody = serializer.Serialize(user); var route = "/api/v1/users"; - var request = new HttpRequestMessage(HttpMethod.Post, route) - { - Content = new StringContent(serializer.Serialize(user)) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - using var client = _testContext.Factory.CreateClient(); - // Act - var response = await client.SendAsync(request); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - response.Should().HaveStatusCode(HttpStatusCode.Created); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - var body = await response.Content.ReadAsStringAsync(); - var returnedUser = GetResponseDeserializer().DeserializeSingle(body).Data; - var document = JsonConvert.DeserializeObject(body); + var responseUser = GetResponseDeserializer().DeserializeSingle(responseDocument).Data; + var document = JsonConvert.DeserializeObject(responseDocument); document.SingleData.Attributes.Should().NotContainKey("password"); document.SingleData.Attributes["userName"].Should().Be(user.UserName); - using var scope = _testContext.Factory.Services.CreateScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - var dbUser = await dbContext.Users.FindAsync(returnedUser.Id); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + var userInDatabase = await dbContext.Users.FirstAsync(u => u.Id == responseUser.Id); - dbUser.UserName.Should().Be(user.UserName); - dbUser.Password.Should().Be(user.Password); + userInDatabase.UserName.Should().Be(user.UserName); + userInDatabase.Password.Should().Be(user.Password); + }); } [Fact] @@ -92,47 +83,34 @@ public async Task Can_update_user_password() // Arrange var user = _fakers.User.Generate(); - using (var scope = _testContext.Factory.Services.CreateScope()) + await _testContext.RunOnDatabaseAsync(async dbContext => { - var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.Users.Add(user); await dbContext.SaveChangesAsync(); - } + }); user.Password = _fakers.User.Generate().Password; var serializer = GetRequestSerializer(p => new {p.Password}); + string requestBody = serializer.Serialize(user); var route = $"/api/v1/users/{user.Id}"; - var request = new HttpRequestMessage(HttpMethod.Patch, route) - { - Content = new StringContent(serializer.Serialize(user)) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - using var client = _testContext.Factory.CreateClient(); - // Act - var response = await client.SendAsync(request); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - response.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - var body = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(body); + responseDocument.SingleData.Attributes.Should().NotContainKey("password"); + responseDocument.SingleData.Attributes["userName"].Should().Be(user.UserName); - document.SingleData.Attributes.Should().NotContainKey("password"); - document.SingleData.Attributes["userName"].Should().Be(user.UserName); - - using (var scope = _testContext.Factory.Services.CreateScope()) + await _testContext.RunOnDatabaseAsync(async dbContext => { - var dbContext = scope.ServiceProvider.GetRequiredService(); - var dbUser = dbContext.Users.Single(u => u.Id == user.Id); + var userInDatabase = await dbContext.Users.FirstAsync(u => u.Id == user.Id); - dbUser.Password.Should().Be(user.Password); - } + userInDatabase.Password.Should().Be(user.Password); + }); } [Fact] @@ -141,21 +119,16 @@ public async Task Unauthorized_TodoItem() // Arrange var route = "/api/v1/todoItems/1337"; - using var client = _testContext.Factory.CreateClient(); - // Act - var response = await client.GetAsync(route); + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); // Assert - response.Should().HaveStatusCode(HttpStatusCode.Forbidden); - - var body = await response.Content.ReadAsStringAsync(); - var errorDocument = JsonConvert.DeserializeObject(body); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - errorDocument.Errors.Should().HaveCount(1); - errorDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); - errorDocument.Errors[0].Title.Should().Be("You are not allowed to update the author of todo items."); - errorDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + responseDocument.Errors[0].Title.Should().Be("You are not allowed to update the author of todo items."); + responseDocument.Errors[0].Detail.Should().BeNull(); } [Fact] @@ -164,21 +137,16 @@ public async Task Unauthorized_Passport() // Arrange var route = "/api/v1/people/1?include=passport"; - using var client = _testContext.Factory.CreateClient(); - // Act - var response = await client.GetAsync(route); + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); // Assert - response.Should().HaveStatusCode(HttpStatusCode.Forbidden); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - var body = await response.Content.ReadAsStringAsync(); - var errorDocument = JsonConvert.DeserializeObject(body); - - errorDocument.Errors.Should().HaveCount(1); - errorDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); - errorDocument.Errors[0].Title.Should().Be("You are not allowed to include passports on individual persons."); - errorDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + responseDocument.Errors[0].Title.Should().Be("You are not allowed to include passports on individual persons."); + responseDocument.Errors[0].Detail.Should().BeNull(); } [Fact] @@ -188,31 +156,24 @@ public async Task Unauthorized_Article() var article = _fakers.Article.Generate(); article.Caption = "Classified"; - using (var scope = _testContext.Factory.Services.CreateScope()) + await _testContext.RunOnDatabaseAsync(async dbContext => { - var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.Articles.Add(article); await dbContext.SaveChangesAsync(); - } + }); var route = $"/api/v1/articles/{article.Id}"; - using var client = _testContext.Factory.CreateClient(); - // Act - var response = await client.GetAsync(route); + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); // Assert - response.Should().HaveStatusCode(HttpStatusCode.Forbidden); - - var body = await response.Content.ReadAsStringAsync(); - var errorDocument = JsonConvert.DeserializeObject(body); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - errorDocument.Errors.Should().HaveCount(1); - errorDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); - errorDocument.Errors[0].Title.Should().Be("You are not allowed to see this article."); - errorDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + responseDocument.Errors[0].Title.Should().Be("You are not allowed to see this article."); + responseDocument.Errors[0].Detail.Should().BeNull(); } [Fact] @@ -223,26 +184,21 @@ public async Task Article_is_hidden() string toBeExcluded = "This should not be included"; articles[0].Caption = toBeExcluded; - using (var scope = _testContext.Factory.Services.CreateScope()) + await _testContext.RunOnDatabaseAsync(async dbContext => { - var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.Articles.AddRange(articles); await dbContext.SaveChangesAsync(); - } + }); var route = "/api/v1/articles"; - using var client = _testContext.Factory.CreateClient(); - // Act - var response = await client.GetAsync(route); + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); // Assert - response.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - var body = await response.Content.ReadAsStringAsync(); - body.Should().NotContain(toBeExcluded); + responseDocument.Should().NotContain(toBeExcluded); } [Fact] @@ -256,26 +212,21 @@ public async Task Article_through_secondary_endpoint_is_hidden() var author = _fakers.Author.Generate(); author.Articles = articles; - using (var scope = _testContext.Factory.Services.CreateScope()) + await _testContext.RunOnDatabaseAsync(async dbContext => { - var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.AuthorDifferentDbContextName.Add(author); await dbContext.SaveChangesAsync(); - } + }); var route = $"/api/v1/authors/{author.Id}/articles"; - using var client = _testContext.Factory.CreateClient(); - // Act - var response = await client.GetAsync(route); + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); // Assert - response.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - var body = await response.Content.ReadAsStringAsync(); - body.Should().NotContain(toBeExcluded); + responseDocument.Should().NotContain(toBeExcluded); } [Fact] @@ -284,33 +235,22 @@ public async Task Passport_Through_Secondary_Endpoint_Is_Hidden() // Arrange var person = _fakers.Person.Generate(); - using (var scope = _testContext.Factory.Services.CreateScope()) + await _testContext.RunOnDatabaseAsync(async dbContext => { - var dbContext = scope.ServiceProvider.GetRequiredService(); - - person.Passport = new Passport(dbContext) - { - IsLocked = true - }; - + person.Passport = new Passport(dbContext) {IsLocked = true}; dbContext.People.Add(person); await dbContext.SaveChangesAsync(); - } + }); var route = $"/api/v1/people/{person.Id}/passport"; - using var client = _testContext.Factory.CreateClient(); - // Act - var response = await client.GetAsync(route); + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); // Assert - response.Should().HaveStatusCode(HttpStatusCode.OK); - - var body = await response.Content.ReadAsStringAsync(); - var document = JsonConvert.DeserializeObject(body); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - document.Data.Should().BeNull(); + responseDocument.Data.Should().BeNull(); } [Fact] @@ -336,13 +276,11 @@ public async Task Tag_is_hidden() } }; - using (var scope = _testContext.Factory.Services.CreateScope()) + await _testContext.RunOnDatabaseAsync(async dbContext => { - var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.ArticleTags.AddRange(articleTags); await dbContext.SaveChangesAsync(); - } + }); // Workaround for https://github.com/dotnet/efcore/issues/21026 var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); @@ -351,16 +289,13 @@ public async Task Tag_is_hidden() var route = "/api/v1/articles?include=tags"; - using var client = _testContext.Factory.CreateClient(); - // Act - var response = await client.GetAsync(route); + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); // Assert - response.Should().HaveStatusCode(HttpStatusCode.OK); + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - var body = await response.Content.ReadAsStringAsync(); - body.Should().NotContain(toBeExcluded); + responseDocument.Should().NotContain(toBeExcluded); } [Fact] @@ -374,16 +309,13 @@ public async Task Cascade_permission_error_create_ToOne_relationship() var lockedPerson = _fakers.Person.Generate(); lockedPerson.IsLocked = true; - using (var scope = _testContext.Factory.Services.CreateScope()) + await _testContext.RunOnDatabaseAsync(async dbContext => { - var dbContext = scope.ServiceProvider.GetRequiredService(); - var passport = new Passport(dbContext); lockedPerson.Passport = passport; - dbContext.People.Add(lockedPerson); await dbContext.SaveChangesAsync(); - } + }); var requestBody = new { @@ -406,27 +338,16 @@ public async Task Cascade_permission_error_create_ToOne_relationship() var route = "/api/v1/people"; - var request = new HttpRequestMessage(HttpMethod.Post, route); - - string requestText = JsonConvert.SerializeObject(requestBody); - request.Content = new StringContent(requestText); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - using var client = _testContext.Factory.CreateClient(); - // Act - var response = await client.SendAsync(request); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - response.Should().HaveStatusCode(HttpStatusCode.Forbidden); - - var body = await response.Content.ReadAsStringAsync(); - var errorDocument = JsonConvert.DeserializeObject(body); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - errorDocument.Errors.Should().HaveCount(1); - errorDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); - errorDocument.Errors[0].Title.Should().Be("You are not allowed to update fields or relationships of locked todo items."); - errorDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + responseDocument.Errors[0].Title.Should().Be("You are not allowed to update fields or relationships of locked todo items."); + responseDocument.Errors[0].Detail.Should().BeNull(); } [Fact] @@ -434,19 +355,18 @@ public async Task Cascade_permission_error_updating_ToOne_relationship() { // Arrange var person = _fakers.Person.Generate(); - Passport newPassport; + Passport newPassport = null; - using (var scope = _testContext.Factory.Services.CreateScope()) + await _testContext.RunOnDatabaseAsync(async dbContext => { - var dbContext = scope.ServiceProvider.GetRequiredService(); - var passport = new Passport(dbContext) {IsLocked = true}; person.Passport = passport; dbContext.People.Add(person); + newPassport = new Passport(dbContext); dbContext.Passports.Add(newPassport); await dbContext.SaveChangesAsync(); - } + }); var requestBody = new { @@ -470,27 +390,16 @@ public async Task Cascade_permission_error_updating_ToOne_relationship() var route = $"/api/v1/people/{person.Id}"; - var request = new HttpRequestMessage(HttpMethod.Patch, route); - - string requestText = JsonConvert.SerializeObject(requestBody); - request.Content = new StringContent(requestText); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - using var client = _testContext.Factory.CreateClient(); - // Act - var response = await client.SendAsync(request); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - response.Should().HaveStatusCode(HttpStatusCode.Forbidden); - - var body = await response.Content.ReadAsStringAsync(); - var errorDocument = JsonConvert.DeserializeObject(body); - - errorDocument.Errors.Should().HaveCount(1); - errorDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); - errorDocument.Errors[0].Title.Should().Be("You are not allowed to update fields or relationships of locked persons."); - errorDocument.Errors[0].Detail.Should().BeNull(); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + responseDocument.Errors[0].Title.Should().Be("You are not allowed to update fields or relationships of locked persons."); + responseDocument.Errors[0].Detail.Should().BeNull(); } [Fact] @@ -499,17 +408,16 @@ public async Task Cascade_permission_error_updating_ToOne_relationship_deletion( // Arrange var person = _fakers.Person.Generate(); - using (var scope = _testContext.Factory.Services.CreateScope()) + await _testContext.RunOnDatabaseAsync(async dbContext => { - var dbContext = scope.ServiceProvider.GetRequiredService(); - var passport = new Passport(dbContext) {IsLocked = true}; person.Passport = passport; dbContext.People.Add(person); + var newPassport = new Passport(dbContext); dbContext.Passports.Add(newPassport); await dbContext.SaveChangesAsync(); - } + }); var requestBody = new { @@ -529,27 +437,16 @@ public async Task Cascade_permission_error_updating_ToOne_relationship_deletion( var route = $"/api/v1/people/{person.Id}"; - var request = new HttpRequestMessage(HttpMethod.Patch, route); - - string requestText = JsonConvert.SerializeObject(requestBody); - request.Content = new StringContent(requestText); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - using var client = _testContext.Factory.CreateClient(); - // Act - var response = await client.SendAsync(request); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - response.Should().HaveStatusCode(HttpStatusCode.Forbidden); - - var body = await response.Content.ReadAsStringAsync(); - var errorDocument = JsonConvert.DeserializeObject(body); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - errorDocument.Errors.Should().HaveCount(1); - errorDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); - errorDocument.Errors[0].Title.Should().Be("You are not allowed to update fields or relationships of locked persons."); - errorDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + responseDocument.Errors[0].Title.Should().Be("You are not allowed to update fields or relationships of locked persons."); + responseDocument.Errors[0].Detail.Should().BeNull(); } [Fact] @@ -559,35 +456,26 @@ public async Task Cascade_permission_error_delete_ToOne_relationship() var lockedPerson = _fakers.Person.Generate(); lockedPerson.IsLocked = true; - using (var scope = _testContext.Factory.Services.CreateScope()) + await _testContext.RunOnDatabaseAsync(async dbContext => { - var dbContext = scope.ServiceProvider.GetRequiredService(); - var passport = new Passport(dbContext); lockedPerson.Passport = passport; dbContext.People.Add(lockedPerson); await dbContext.SaveChangesAsync(); - } + }); var route = $"/api/v1/passports/{lockedPerson.Passport.StringId}"; - var request = new HttpRequestMessage(HttpMethod.Delete, route); - - using var client = _testContext.Factory.CreateClient(); - // Act - var response = await client.SendAsync(request); + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); // Assert - response.Should().HaveStatusCode(HttpStatusCode.Forbidden); - - var body = await response.Content.ReadAsStringAsync(); - var errorDocument = JsonConvert.DeserializeObject(body); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - errorDocument.Errors.Should().HaveCount(1); - errorDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); - errorDocument.Errors[0].Title.Should().Be("You are not allowed to update fields or relationships of locked todo items."); - errorDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + responseDocument.Errors[0].Title.Should().Be("You are not allowed to update fields or relationships of locked todo items."); + responseDocument.Errors[0].Detail.Should().BeNull(); } [Fact] @@ -599,13 +487,11 @@ public async Task Cascade_permission_error_create_ToMany_relationship() lockedTodo.IsLocked = true; lockedTodo.StakeHolders = persons.ToHashSet(); - using (var scope = _testContext.Factory.Services.CreateScope()) + await _testContext.RunOnDatabaseAsync(async dbContext => { - var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.TodoItems.Add(lockedTodo); await dbContext.SaveChangesAsync(); - } + }); var requestBody = new { @@ -636,27 +522,16 @@ public async Task Cascade_permission_error_create_ToMany_relationship() var route = "/api/v1/todoItems"; - var request = new HttpRequestMessage(HttpMethod.Post, route); - - string requestText = JsonConvert.SerializeObject(requestBody); - request.Content = new StringContent(requestText); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - using var client = _testContext.Factory.CreateClient(); - // Act - var response = await client.SendAsync(request); + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert - response.Should().HaveStatusCode(HttpStatusCode.Forbidden); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - var body = await response.Content.ReadAsStringAsync(); - var errorDocument = JsonConvert.DeserializeObject(body); - - errorDocument.Errors.Should().HaveCount(1); - errorDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); - errorDocument.Errors[0].Title.Should().Be("You are not allowed to update fields or relationships of locked todo items."); - errorDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + responseDocument.Errors[0].Title.Should().Be("You are not allowed to update fields or relationships of locked todo items."); + responseDocument.Errors[0].Detail.Should().BeNull(); } [Fact] @@ -671,13 +546,11 @@ public async Task Cascade_permission_error_updating_ToMany_relationship() var unlockedTodo = _fakers.TodoItem.Generate(); - using (var scope = _testContext.Factory.Services.CreateScope()) + await _testContext.RunOnDatabaseAsync(async dbContext => { - var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.TodoItems.AddRange(lockedTodo, unlockedTodo); await dbContext.SaveChangesAsync(); - } + }); var requestBody = new { @@ -709,27 +582,16 @@ public async Task Cascade_permission_error_updating_ToMany_relationship() var route = $"/api/v1/todoItems/{unlockedTodo.Id}"; - var request = new HttpRequestMessage(HttpMethod.Patch, route); - - string requestText = JsonConvert.SerializeObject(requestBody); - request.Content = new StringContent(requestText); - request.Content.Headers.ContentType = new MediaTypeHeaderValue(HeaderConstants.MediaType); - - using var client = _testContext.Factory.CreateClient(); - // Act - var response = await client.SendAsync(request); + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); // Assert - response.Should().HaveStatusCode(HttpStatusCode.Forbidden); - - var body = await response.Content.ReadAsStringAsync(); - var errorDocument = JsonConvert.DeserializeObject(body); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - errorDocument.Errors.Should().HaveCount(1); - errorDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); - errorDocument.Errors[0].Title.Should().Be("You are not allowed to update fields or relationships of locked todo items."); - errorDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + responseDocument.Errors[0].Title.Should().Be("You are not allowed to update fields or relationships of locked todo items."); + responseDocument.Errors[0].Detail.Should().BeNull(); } [Fact] @@ -741,33 +603,24 @@ public async Task Cascade_permission_error_delete_ToMany_relationship() lockedTodo.IsLocked = true; lockedTodo.StakeHolders = persons.ToHashSet(); - using (var scope = _testContext.Factory.Services.CreateScope()) + await _testContext.RunOnDatabaseAsync(async dbContext => { - var dbContext = scope.ServiceProvider.GetRequiredService(); - dbContext.TodoItems.Add(lockedTodo); await dbContext.SaveChangesAsync(); - } + }); var route = $"/api/v1/people/{persons[0].Id}"; - var request = new HttpRequestMessage(HttpMethod.Delete, route); - - using var client = _testContext.Factory.CreateClient(); - // Act - var response = await client.SendAsync(request); + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); // Assert - response.Should().HaveStatusCode(HttpStatusCode.Forbidden); - - var body = await response.Content.ReadAsStringAsync(); - var errorDocument = JsonConvert.DeserializeObject(body); + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - errorDocument.Errors.Should().HaveCount(1); - errorDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); - errorDocument.Errors[0].Title.Should().Be("You are not allowed to update fields or relationships of locked todo items."); - errorDocument.Errors[0].Detail.Should().BeNull(); + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.Forbidden); + responseDocument.Errors[0].Title.Should().Be("You are not allowed to update fields or relationships of locked todo items."); + responseDocument.Errors[0].Detail.Should().BeNull(); } private IRequestSerializer GetRequestSerializer(Expression> attributes = null, From 60ac85608c216e6abdd7ed032c691b0178bda19a Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 4 Feb 2021 19:09:00 +0100 Subject: [PATCH 104/123] Cleanup example models --- .../Data/AppDbContext.cs | 12 +---- .../Models/Address.cs | 3 -- .../JsonApiDotNetCoreExample/Models/Gender.cs | 9 ---- .../Models/Passport.cs | 44 ---------------- .../JsonApiDotNetCoreExample/Models/Person.cs | 6 --- .../Models/SuperUser.cs | 10 ++++ .../Models/TodoItem.cs | 7 --- .../JsonApiDotNetCoreExample/Models/User.cs | 37 +------------ .../ExampleFakers.cs | 35 +++---------- .../PaginationWithTotalCountTests.cs | 4 +- .../PaginationWithoutTotalCountTests.cs | 4 +- .../Pagination/RangeValidationTests.cs | 4 +- .../ResourceHooks/ResourceHookTests.cs | 52 ++++++++----------- .../ResourceHooks/ResourceHooksStartup.cs | 4 -- .../IntegrationTests/Sorting/SortTests.cs | 4 +- .../SparseFieldSets/SparseFieldSetTests.cs | 4 +- .../EntityFrameworkCoreRepositoryTests.cs | 3 +- .../IServiceCollectionExtensionsTests.cs | 4 -- .../ResourceConstructionExpressionTests.cs | 32 ------------ .../Models/ResourceConstructionTests.cs | 37 ------------- .../ResourceHooks/ResourceHooksTestsSetup.cs | 13 ++--- 21 files changed, 51 insertions(+), 277 deletions(-) delete mode 100644 src/Examples/JsonApiDotNetCoreExample/Models/Gender.cs create mode 100644 src/Examples/JsonApiDotNetCoreExample/Models/SuperUser.cs diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index 6107186144..2f3e590dac 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -1,26 +1,19 @@ -using System; using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; namespace JsonApiDotNetCoreExample.Data { public sealed class AppDbContext : DbContext { - public ISystemClock SystemClock { get; } - public DbSet TodoItems { get; set; } - public DbSet Passports { get; set; } public DbSet People { get; set; } public DbSet
Articles { get; set; } public DbSet AuthorDifferentDbContextName { get; set; } public DbSet Users { get; set; } - public DbSet ArticleTags { get; set; } public DbSet Blogs { get; set; } - public AppDbContext(DbContextOptions options, ISystemClock systemClock) : base(options) + public AppDbContext(DbContextOptions options) : base(options) { - SystemClock = systemClock ?? throw new ArgumentNullException(nameof(systemClock)); } protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -49,9 +42,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithMany(t => t.StakeHolders) .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() - .HasOne(t => t.DependentOnTodo); - modelBuilder.Entity() .HasMany(t => t.ChildrenTodos) .WithOne(t => t.ParentTodo); diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Address.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Address.cs index a84436df31..2388a96729 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Address.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Address.cs @@ -8,9 +8,6 @@ public sealed class Address : Identifiable [Attr] public string Street { get; set; } - [Attr] - public string ZipCode { get; set; } - [HasOne] public Country Country { get; set; } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Gender.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Gender.cs deleted file mode 100644 index 4990932a0a..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Gender.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace JsonApiDotNetCoreExample.Models -{ - public enum Gender - { - Unknown, - Male, - Female - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs index 16daf865f5..5b09f0398c 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Passport.cs @@ -1,58 +1,14 @@ -using System; -using System.ComponentModel.DataAnnotations.Schema; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCoreExample.Data; -using Microsoft.AspNetCore.Authentication; namespace JsonApiDotNetCoreExample.Models { public class Passport : Identifiable { - private readonly ISystemClock _systemClock; - private int? _socialSecurityNumber; - - [Attr] - public int? SocialSecurityNumber - { - get => _socialSecurityNumber; - set - { - if (value != _socialSecurityNumber) - { - LastSocialSecurityNumberChange = _systemClock.UtcNow.LocalDateTime; - _socialSecurityNumber = value; - } - } - } - - [Attr] - public DateTime LastSocialSecurityNumberChange { get; set; } - [Attr] public bool IsLocked { get; set; } [HasOne] public Person Person { get; set; } - - [Attr] - [NotMapped] - public string BirthCountryName - { - get => BirthCountry?.Name; - set - { - BirthCountry ??= new Country(); - BirthCountry.Name = value; - } - } - - [EagerLoad] - public Country BirthCountry { get; set; } - - public Passport(AppDbContext appDbContext) - { - _systemClock = appDbContext.SystemClock; - } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs index b5d67fb5a0..46ca00f02b 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs @@ -40,12 +40,6 @@ public string FirstName [Attr(PublicName = "the-Age")] public int Age { get; set; } - [Attr] - public Gender Gender { get; set; } - - [Attr] - public string Category { get; set; } - [HasMany] public ISet TodoItems { get; set; } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/SuperUser.cs b/src/Examples/JsonApiDotNetCoreExample/Models/SuperUser.cs new file mode 100644 index 0000000000..660ddd3064 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Models/SuperUser.cs @@ -0,0 +1,10 @@ +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExample.Models +{ + public sealed class SuperUser : User + { + [Attr] + public int SecurityLevel { get; set; } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index abef810db1..afa4436d8f 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs @@ -24,9 +24,6 @@ public class TodoItem : Identifiable, IIsLockable [Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))] public string CalculatedValue => "calculated"; - [Attr(Capabilities = AttrCapabilities.All & ~AttrCapabilities.AllowChange)] - public DateTimeOffset? OffsetDate { get; set; } - [HasOne] public Person Owner { get; set; } @@ -42,10 +39,6 @@ public class TodoItem : Identifiable, IIsLockable [HasOne] public TodoItemCollection Collection { get; set; } - // cyclical to-one structure - [HasOne] - public TodoItem DependentOnTodo { get; set; } - // cyclical to-many structure [HasOne] public TodoItem ParentTodo { get; set; } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/User.cs b/src/Examples/JsonApiDotNetCoreExample/Models/User.cs index dea3f26be5..f61cf6e7ef 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/User.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/User.cs @@ -1,49 +1,14 @@ -using System; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCoreExample.Data; -using Microsoft.AspNetCore.Authentication; namespace JsonApiDotNetCoreExample.Models { public class User : Identifiable { - private readonly ISystemClock _systemClock; - private string _password; - [Attr] public string UserName { get; set; } [Attr(Capabilities = AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange)] - public string Password - { - get => _password; - set - { - if (value != _password) - { - _password = value; - LastPasswordChange = _systemClock.UtcNow.LocalDateTime; - } - } - } - - [Attr] - public DateTime LastPasswordChange { get; set; } - - public User(AppDbContext appDbContext) - { - _systemClock = appDbContext.SystemClock; - } - } - - public sealed class SuperUser : User - { - [Attr] - public int SecurityLevel { get; set; } - - public SuperUser(AppDbContext appDbContext) : base(appDbContext) - { - } + public string Password { get; set; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/ExampleFakers.cs b/test/JsonApiDotNetCoreExampleTests/ExampleFakers.cs index a8908ad93e..b1dfec3fe3 100644 --- a/test/JsonApiDotNetCoreExampleTests/ExampleFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/ExampleFakers.cs @@ -1,8 +1,6 @@ using System; using Bogus; -using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; -using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Person = JsonApiDotNetCoreExample.Models.Person; @@ -10,8 +8,6 @@ namespace JsonApiDotNetCoreExampleTests { internal sealed class ExampleFakers : FakerContainer { - private readonly IServiceProvider _serviceProvider; - private readonly Lazy> _lazyAuthorFaker = new Lazy>(() => new Faker() .UseSeed(GetFakerSeed()) @@ -26,7 +22,11 @@ internal sealed class ExampleFakers : FakerContainer .RuleFor(article => article.Caption, f => f.Lorem.Word()) .RuleFor(article => article.Url, f => f.Internet.Url())); - private readonly Lazy> _lazyUserFaker; + private readonly Lazy> _lazyUserFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(user => user.UserName, f => f.Person.UserName) + .RuleFor(user => user.Password, f => f.Internet.Password())); private readonly Lazy> _lazyTodoItemFaker = new Lazy>(() => new Faker() @@ -34,17 +34,14 @@ internal sealed class ExampleFakers : FakerContainer .RuleFor(todoItem => todoItem.Description, f => f.Random.Words()) .RuleFor(todoItem => todoItem.Ordinal, f => f.Random.Long(1, 999999)) .RuleFor(todoItem => todoItem.CreatedDate, f => f.Date.Past()) - .RuleFor(todoItem => todoItem.AchievedDate, f => f.Date.Past()) - .RuleFor(todoItem => todoItem.OffsetDate, f => f.Date.FutureOffset())); + .RuleFor(todoItem => todoItem.AchievedDate, f => f.Date.Past())); private readonly Lazy> _lazyPersonFaker = new Lazy>(() => new Faker() .UseSeed(GetFakerSeed()) .RuleFor(person => person.FirstName, f => f.Person.FirstName) .RuleFor(person => person.LastName, f => f.Person.LastName) - .RuleFor(person => person.Age, f => f.Random.Int(25, 50)) - .RuleFor(person => person.Gender, f => f.PickRandom()) - .RuleFor(person => person.Category, f => f.Lorem.Word())); + .RuleFor(person => person.Age, f => f.Random.Int(25, 50))); private readonly Lazy> _lazyTagFaker = new Lazy>(() => new Faker() @@ -58,23 +55,5 @@ internal sealed class ExampleFakers : FakerContainer public Faker TodoItem => _lazyTodoItemFaker.Value; public Faker Person => _lazyPersonFaker.Value; public Faker Tag => _lazyTagFaker.Value; - - public ExampleFakers(IServiceProvider serviceProvider) - { - _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - - _lazyUserFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .CustomInstantiator(f => new User(ResolveDbContext())) - .RuleFor(user => user.UserName, f => f.Person.UserName) - .RuleFor(user => user.Password, f => f.Internet.Password())); - } - - private AppDbContext ResolveDbContext() - { - using var scope = _serviceProvider.CreateScope(); - return scope.ServiceProvider.GetRequiredService(); - } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs index fb9d31449b..70147ee66a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs @@ -20,7 +20,7 @@ public sealed class PaginationWithTotalCountTests : IClassFixture _testContext; - private readonly ExampleFakers _fakers; + private readonly ExampleFakers _fakers = new ExampleFakers(); public PaginationWithTotalCountTests(ExampleIntegrationTestContext testContext) { @@ -35,8 +35,6 @@ public PaginationWithTotalCountTests(ExampleIntegrationTestContext _testContext; - private readonly ExampleFakers _fakers; + private readonly ExampleFakers _fakers = new ExampleFakers(); public PaginationWithoutTotalCountTests(ExampleIntegrationTestContext testContext) { @@ -28,8 +28,6 @@ public PaginationWithoutTotalCountTests(ExampleIntegrationTestContext> { private readonly ExampleIntegrationTestContext _testContext; - private readonly ExampleFakers _fakers; + private readonly ExampleFakers _fakers = new ExampleFakers(); private const int _defaultPageSize = 5; @@ -27,8 +27,6 @@ public RangeValidationTests(ExampleIntegrationTestContext options.DefaultPageSize = new PageSize(_defaultPageSize); options.MaximumPageSize = null; options.MaximumPageNumber = null; - - _fakers = new ExampleFakers(testContext.Factory.Services); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs index 506d13152d..f7b744e9bb 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHookTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using System.Net; @@ -23,7 +24,7 @@ public sealed class ResourceHookTests : IClassFixture, AppDbContext>> { private readonly ExampleIntegrationTestContext, AppDbContext> _testContext; - private readonly ExampleFakers _fakers; + private readonly ExampleFakers _fakers = new ExampleFakers(); public ResourceHookTests(ExampleIntegrationTestContext, AppDbContext> testContext) { @@ -36,13 +37,13 @@ public ResourceHookTests(ExampleIntegrationTestContext, PersonHooksDefinition>(); services.AddScoped, TagHooksDefinition>(); services.AddScoped, TodoItemHooksDefinition>(); + + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); }); var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); options.DisableTopPagination = false; options.DisableChildrenPagination = false; - - _fakers = new ExampleFakers(testContext.Factory.Services); } [Fact] @@ -205,12 +206,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Article_through_secondary_endpoint_is_hidden() { // Arrange - var articles = _fakers.Article.Generate(3); string toBeExcluded = "This should not be included"; - articles[0].Caption = toBeExcluded; var author = _fakers.Author.Generate(); - author.Articles = articles; + author.Articles = _fakers.Article.Generate(3); + author.Articles[0].Caption = toBeExcluded; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -234,10 +234,10 @@ public async Task Passport_Through_Secondary_Endpoint_Is_Hidden() { // Arrange var person = _fakers.Person.Generate(); + person.Passport = new Passport {IsLocked = true}; await _testContext.RunOnDatabaseAsync(async dbContext => { - person.Passport = new Passport(dbContext) {IsLocked = true}; dbContext.People.Add(person); await dbContext.SaveChangesAsync(); }); @@ -257,28 +257,27 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Tag_is_hidden() { // Arrange - var article = _fakers.Article.Generate(); - var tags = _fakers.Tag.Generate(2); string toBeExcluded = "This should not be included"; + + var tags = _fakers.Tag.Generate(2); tags[0].Name = toBeExcluded; - var articleTags = new[] + var article = _fakers.Article.Generate(); + article.ArticleTags = new HashSet { new ArticleTag { - Article = article, Tag = tags[0] }, new ArticleTag { - Article = article, Tag = tags[1] } }; await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.ArticleTags.AddRange(articleTags); + dbContext.Articles.Add(article); await dbContext.SaveChangesAsync(); }); @@ -308,11 +307,10 @@ public async Task Cascade_permission_error_create_ToOne_relationship() // Arrange var lockedPerson = _fakers.Person.Generate(); lockedPerson.IsLocked = true; + lockedPerson.Passport = new Passport(); await _testContext.RunOnDatabaseAsync(async dbContext => { - var passport = new Passport(dbContext); - lockedPerson.Passport = passport; dbContext.People.Add(lockedPerson); await dbContext.SaveChangesAsync(); }); @@ -355,16 +353,13 @@ public async Task Cascade_permission_error_updating_ToOne_relationship() { // Arrange var person = _fakers.Person.Generate(); - Passport newPassport = null; + person.Passport = new Passport {IsLocked = true}; + + var newPassport = new Passport(); await _testContext.RunOnDatabaseAsync(async dbContext => { - var passport = new Passport(dbContext) {IsLocked = true}; - person.Passport = passport; - dbContext.People.Add(person); - - newPassport = new Passport(dbContext); - dbContext.Passports.Add(newPassport); + dbContext.AddRange(person, newPassport); await dbContext.SaveChangesAsync(); }); @@ -407,15 +402,13 @@ public async Task Cascade_permission_error_updating_ToOne_relationship_deletion( { // Arrange var person = _fakers.Person.Generate(); + person.Passport = new Passport {IsLocked = true}; + + var newPassport = new Passport(); await _testContext.RunOnDatabaseAsync(async dbContext => { - var passport = new Passport(dbContext) {IsLocked = true}; - person.Passport = passport; - dbContext.People.Add(person); - - var newPassport = new Passport(dbContext); - dbContext.Passports.Add(newPassport); + dbContext.AddRange(person, newPassport); await dbContext.SaveChangesAsync(); }); @@ -455,11 +448,10 @@ public async Task Cascade_permission_error_delete_ToOne_relationship() // Arrange var lockedPerson = _fakers.Person.Generate(); lockedPerson.IsLocked = true; + lockedPerson.Passport = new Passport(); await _testContext.RunOnDatabaseAsync(async dbContext => { - var passport = new Passport(dbContext); - lockedPerson.Passport = passport; dbContext.People.Add(lockedPerson); await dbContext.SaveChangesAsync(); }); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHooksStartup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHooksStartup.cs index af60191944..fdc523810d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHooksStartup.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHooksStartup.cs @@ -1,9 +1,7 @@ using JsonApiDotNetCore.Configuration; -using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceHooks { @@ -17,8 +15,6 @@ public ResourceHooksStartup(IConfiguration configuration) public override void ConfigureServices(IServiceCollection services) { - services.AddSingleton(); - base.ConfigureServices(services); services.AddControllersFromExampleProject(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs index 79b8b21629..bf9b98f424 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs @@ -16,13 +16,11 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Sorting public sealed class SortTests : IClassFixture> { private readonly ExampleIntegrationTestContext _testContext; - private readonly ExampleFakers _fakers; + private readonly ExampleFakers _fakers = new ExampleFakers(); public SortTests(ExampleIntegrationTestContext testContext) { _testContext = testContext; - - _fakers = new ExampleFakers(testContext.Factory.Services); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs index 37db55b381..e478129b84 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs @@ -19,7 +19,7 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SparseFieldSets public sealed class SparseFieldSetTests : IClassFixture> { private readonly ExampleIntegrationTestContext _testContext; - private readonly ExampleFakers _fakers; + private readonly ExampleFakers _fakers = new ExampleFakers(); public SparseFieldSetTests(ExampleIntegrationTestContext testContext) { @@ -36,8 +36,6 @@ public SparseFieldSetTests(ExampleIntegrationTestContext services.AddScoped, JsonApiResourceService
>(); }); - - _fakers = new ExampleFakers(testContext.Factory.Services); } [Fact] diff --git a/test/UnitTests/Data/EntityFrameworkCoreRepositoryTests.cs b/test/UnitTests/Data/EntityFrameworkCoreRepositoryTests.cs index 0d8b177ddc..27acbf0439 100644 --- a/test/UnitTests/Data/EntityFrameworkCoreRepositoryTests.cs +++ b/test/UnitTests/Data/EntityFrameworkCoreRepositoryTests.cs @@ -15,7 +15,6 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using Moq; -using TestBuildingBlocks; using Xunit; namespace UnitTests.Data @@ -102,7 +101,7 @@ private AppDbContext GetDbContext(Guid? seed = null) var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: $"IntegrationDatabaseRepository{actualSeed}") .Options; - var context = new AppDbContext(options, new FrozenSystemClock()); + var context = new AppDbContext(options); context.RemoveRange(context.TodoItems); return context; diff --git a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs index bc6318b774..533e69b5a0 100644 --- a/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -14,11 +14,9 @@ using JsonApiDotNetCore.Services; using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; using Xunit; namespace UnitTests.Extensions @@ -31,7 +29,6 @@ public void AddJsonApiInternals_Adds_All_Required_Services() // Arrange var services = new ServiceCollection(); services.AddLogging(); - services.AddSingleton(); services.AddDbContext(options => options.UseInMemoryDatabase("UnitTestDb")); services.AddJsonApi(); @@ -66,7 +63,6 @@ public void RegisterResource_DeviatingDbContextPropertyName_RegistersCorrectly() // Arrange var services = new ServiceCollection(); services.AddLogging(); - services.AddSingleton(); services.AddDbContext(options => options.UseInMemoryDatabase("UnitTestDb")); services.AddJsonApi(); diff --git a/test/UnitTests/Models/ResourceConstructionExpressionTests.cs b/test/UnitTests/Models/ResourceConstructionExpressionTests.cs index 71a4f9642c..45f5125cb7 100644 --- a/test/UnitTests/Models/ResourceConstructionExpressionTests.cs +++ b/test/UnitTests/Models/ResourceConstructionExpressionTests.cs @@ -2,10 +2,6 @@ using System.ComponentModel.Design; using System.Linq.Expressions; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCoreExample.Data; -using Microsoft.AspNetCore.Authentication; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; using Xunit; namespace UnitTests.Models @@ -30,34 +26,6 @@ public void When_resource_has_default_constructor_it_must_succeed() Assert.NotNull(resource); } - [Fact] - public void When_resource_has_constructor_with_injectable_parameter_it_must_succeed() - { - // Arrange - var contextOptions = new DbContextOptionsBuilder().Options; - var systemClock = new FrozenSystemClock(); - var appDbContext = new AppDbContext(contextOptions, systemClock); - - using var serviceContainer = new ServiceContainer(); - serviceContainer.AddService(typeof(DbContextOptions), contextOptions); - serviceContainer.AddService(typeof(ISystemClock), systemClock); - serviceContainer.AddService(typeof(AppDbContext), appDbContext); - - var factory = new ResourceFactory(serviceContainer); - - // Act - NewExpression newExpression = factory.CreateNewExpression(typeof(ResourceWithDbContextConstructor)); - - // Assert - var function = Expression - .Lambda>(newExpression) - .Compile(); - - ResourceWithDbContextConstructor resource = function(); - Assert.NotNull(resource); - Assert.Equal(appDbContext, resource.AppDbContext); - } - [Fact] public void When_resource_has_constructor_with_string_parameter_it_must_fail() { diff --git a/test/UnitTests/Models/ResourceConstructionTests.cs b/test/UnitTests/Models/ResourceConstructionTests.cs index bfae197a83..7ec46bd674 100644 --- a/test/UnitTests/Models/ResourceConstructionTests.cs +++ b/test/UnitTests/Models/ResourceConstructionTests.cs @@ -6,11 +6,9 @@ using JsonApiDotNetCore.Serialization; using JsonApiDotNetCoreExample.Data; using Microsoft.AspNetCore.Http; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Newtonsoft.Json; -using TestBuildingBlocks; using Xunit; namespace UnitTests.Models @@ -88,41 +86,6 @@ public void When_resource_has_default_constructor_that_throws_it_must_fail() exception.Message); } - [Fact] - public void When_resource_has_constructor_with_injectable_parameter_it_must_succeed() - { - // Arrange - var graph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance) - .Add() - .Build(); - - var appDbContext = new AppDbContext(new DbContextOptionsBuilder().Options, new FrozenSystemClock()); - - var serviceContainer = new ServiceContainer(); - serviceContainer.AddService(typeof(AppDbContext), appDbContext); - - var serializer = new RequestDeserializer(graph, new ResourceFactory(serviceContainer), new TargetedFields(), _mockHttpContextAccessor.Object, _requestMock.Object); - - var body = new - { - data = new - { - id = "1", - type = "resourceWithDbContextConstructors" - } - }; - - string content = JsonConvert.SerializeObject(body); - - // Act - object result = serializer.Deserialize(content); - - // Assert - Assert.NotNull(result); - Assert.Equal(typeof(ResourceWithDbContextConstructor), result.GetType()); - Assert.Equal(appDbContext, ((ResourceWithDbContextConstructor)result).AppDbContext); - } - [Fact] public void When_resource_has_constructor_with_string_parameter_it_must_fail() { diff --git a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs index bcfdba7cf1..cb0c98ae8f 100644 --- a/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/ResourceHooksTestsSetup.cs @@ -19,7 +19,6 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using Moq; -using TestBuildingBlocks; using Person = JsonApiDotNetCoreExample.Models.Person; namespace UnitTests.ResourceHooks @@ -40,8 +39,6 @@ public class HooksDummyData public HooksDummyData() { - var appDbContext = new AppDbContext(new DbContextOptionsBuilder().Options, new FrozenSystemClock()); - _resourceGraph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance) .Add() .Add() @@ -56,14 +53,12 @@ public HooksDummyData() _personFaker = new Faker().Rules((f, i) => i.Id = f.UniqueIndex + 1); _articleFaker = new Faker
().Rules((f, i) => i.Id = f.UniqueIndex + 1); - _articleTagFaker = new Faker().CustomInstantiator(f => new ArticleTag()); + _articleTagFaker = new Faker(); _identifiableArticleTagFaker = new Faker().Rules((f, i) => i.Id = f.UniqueIndex + 1); _tagFaker = new Faker() - .CustomInstantiator(f => new Tag()) .Rules((f, i) => i.Id = f.UniqueIndex + 1); _passportFaker = new Faker() - .CustomInstantiator(f => new Passport(appDbContext)) .Rules((f, i) => i.Id = f.UniqueIndex + 1); } @@ -194,7 +189,7 @@ public class HooksTestsSetup : HooksDummyData // mocking the genericServiceFactory and JsonApiContext and wiring them up. var (ufMock, constraintsMock, gpfMock, options) = CreateMocks(); - var dbContext = repoDbContextOptions != null ? new AppDbContext(repoDbContextOptions, new FrozenSystemClock()) : null; + var dbContext = repoDbContextOptions != null ? new AppDbContext(repoDbContextOptions) : null; var resourceGraph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance) .Add() @@ -230,7 +225,7 @@ public class HooksTestsSetup : HooksDummyData // mocking the genericServiceFactory and JsonApiContext and wiring them up. var (ufMock, constraintsMock, gpfMock, options) = CreateMocks(); - var dbContext = repoDbContextOptions != null ? new AppDbContext(repoDbContextOptions, new FrozenSystemClock()) : null; + var dbContext = repoDbContextOptions != null ? new AppDbContext(repoDbContextOptions) : null; var resourceGraph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance) .Add() @@ -281,7 +276,7 @@ protected DbContextOptions InitInMemoryDb(Action seeder .UseInMemoryDatabase(databaseName: "repository_mock") .Options; - using (var context = new AppDbContext(options, new FrozenSystemClock())) + using (var context = new AppDbContext(options)) { seeder(context); ResolveInverseRelationships(context); From de40fb71f773883486c58a8cc61466e50440bebe Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 4 Feb 2021 19:11:57 +0100 Subject: [PATCH 105/123] Revert "Enable concurrent testruns (makes running all tests 60% faster)" to investigate why cibuild hangs This reverts commit d3ff09a1a1ca2ed8c6bd8a7e47c6b7733cecbd6f. --- .../JsonApiDotNetCoreExampleTests.csproj | 6 ++++++ test/JsonApiDotNetCoreExampleTests/xunit.runner.json | 5 +++++ 2 files changed, 11 insertions(+) create mode 100644 test/JsonApiDotNetCoreExampleTests/xunit.runner.json diff --git a/test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj b/test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj index 8bf9f701f4..77e604e150 100644 --- a/test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj +++ b/test/JsonApiDotNetCoreExampleTests/JsonApiDotNetCoreExampleTests.csproj @@ -3,6 +3,12 @@ $(NetCoreAppVersion) + + + PreserveNewest + + + diff --git a/test/JsonApiDotNetCoreExampleTests/xunit.runner.json b/test/JsonApiDotNetCoreExampleTests/xunit.runner.json new file mode 100644 index 0000000000..8f5f10571b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "parallelizeAssembly": false, + "parallelizeTestCollections": false, + "maxParallelThreads": 1 +} From bd11b521cb8b2cf51f3eca88831fa6a49d9f26ed Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 4 Feb 2021 19:32:03 +0100 Subject: [PATCH 106/123] fixed failing testrunner --- test/TestBuildingBlocks/TestBuildingBlocks.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/test/TestBuildingBlocks/TestBuildingBlocks.csproj b/test/TestBuildingBlocks/TestBuildingBlocks.csproj index 42f159dfa1..6f81675106 100644 --- a/test/TestBuildingBlocks/TestBuildingBlocks.csproj +++ b/test/TestBuildingBlocks/TestBuildingBlocks.csproj @@ -8,6 +8,7 @@ + From b0ac024e3b6180c2e45bce3f1c0b491ce0d46f29 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 4 Feb 2021 19:43:57 +0100 Subject: [PATCH 107/123] Enable running integration tests in parallel --- test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj | 6 ------ test/NoEntityFrameworkTests/xunit.runner.json | 4 ---- 2 files changed, 10 deletions(-) delete mode 100644 test/NoEntityFrameworkTests/xunit.runner.json diff --git a/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj b/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj index 42964abb58..91b2c655e7 100644 --- a/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj +++ b/test/NoEntityFrameworkTests/NoEntityFrameworkTests.csproj @@ -3,12 +3,6 @@ $(NetCoreAppVersion) - - - PreserveNewest - - - diff --git a/test/NoEntityFrameworkTests/xunit.runner.json b/test/NoEntityFrameworkTests/xunit.runner.json deleted file mode 100644 index 9db029ba52..0000000000 --- a/test/NoEntityFrameworkTests/xunit.runner.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "parallelizeAssembly": false, - "parallelizeTestCollections": false -} From f343d084fb29b8ce83ba819e3ef123dbaf24400d Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 4 Feb 2021 19:50:38 +0100 Subject: [PATCH 108/123] test From 38c127acf5d8b5cf7ed0b83ef65de5b25761c0e2 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 4 Feb 2021 19:57:21 +0100 Subject: [PATCH 109/123] Disable duplicate builds --- appveyor.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/appveyor.yml b/appveyor.yml index a3aca5fccc..ba94dfde6b 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,6 +1,9 @@ version: '{build}' os: Visual Studio 2019 +# Do not build feature branch with open Pull Requests +skip_branch_with_pr: true + environment: PGUSER: postgres PGPASSWORD: Password12! From 9ee3fc59d03df16771ed126cb1be6ce39756bddf Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Thu, 4 Feb 2021 20:01:44 +0100 Subject: [PATCH 110/123] Revert "Disable duplicate builds" This reverts commit 38c127acf5d8b5cf7ed0b83ef65de5b25761c0e2. --- appveyor.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index ba94dfde6b..a3aca5fccc 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,9 +1,6 @@ version: '{build}' os: Visual Studio 2019 -# Do not build feature branch with open Pull Requests -skip_branch_with_pr: true - environment: PGUSER: postgres PGPASSWORD: Password12! From 59fccd81e08248476004dcc0d06813d78a3cd576 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 5 Feb 2021 10:06:20 +0100 Subject: [PATCH 111/123] Addressed review feedback --- .../ExceptionHandling/ConsumerArticleService.cs | 4 +++- .../ExceptionHandling/ExceptionHandlerTests.cs | 2 +- .../IntegrationTests/NamingConventions/KebabCasingTests.cs | 4 ++-- .../IntegrationTests/RestrictedControllers/Bed.cs | 3 +++ .../IntegrationTests/RestrictedControllers/Chair.cs | 4 +++- .../IntegrationTests/RestrictedControllers/Sofa.cs | 3 +++ .../IntegrationTests/RestrictedControllers/Table.cs | 4 +++- 7 files changed, 18 insertions(+), 6 deletions(-) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs index 17daa6233a..2d867ea0f7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs @@ -13,6 +13,8 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling { public sealed class ConsumerArticleService : JsonApiResourceService { + public const string UnavailableArticlePrefix = "X"; + private const string _supportEmailAddress = "company@email.com"; public ConsumerArticleService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, @@ -28,7 +30,7 @@ public override async Task GetAsync(int id, CancellationToken c { var consumerArticle = await base.GetAsync(id, cancellationToken); - if (consumerArticle.Code.StartsWith("X")) + if (consumerArticle.Code.StartsWith(UnavailableArticlePrefix)) { throw new ConsumerArticleIsNoLongerAvailableException(consumerArticle.Code, _supportEmailAddress); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs index a03f9060aa..09e946dc11 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs @@ -57,7 +57,7 @@ public async Task Logs_and_produces_error_response_for_custom_exception() var consumerArticle = new ConsumerArticle { - Code = "X123" + Code = ConsumerArticleService.UnavailableArticlePrefix + "123" }; await _testContext.RunOnDatabaseAsync(async dbContext => diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs index a19dee6ab3..08d4d28f3f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs @@ -135,7 +135,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_create_resource_for_invalid_request_body() + public async Task Applies_casing_convention_on_error_stack_trace() { // Arrange var requestBody = "{ \"data\": {"; @@ -155,7 +155,7 @@ public async Task Cannot_create_resource_for_invalid_request_body() } [Fact] - public async Task Cannot_update_resource_for_invalid_attribute() + public async Task Applies_casing_convention_on_source_pointer_from_ModelState() { // Arrange var existingBoard = _fakers.DivingBoard.Generate(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Bed.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Bed.cs index 3aa4747c9c..8c84293143 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Bed.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Bed.cs @@ -1,8 +1,11 @@ using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers { public sealed class Bed : Identifiable { + [Attr] + public bool IsDouble { get; set; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Chair.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Chair.cs index e80794f0e7..1948563f01 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Chair.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Chair.cs @@ -1,9 +1,11 @@ using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers { public sealed class Chair : Identifiable { - + [Attr] + public int LegCount { get; set; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Sofa.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Sofa.cs index 4c5c84ce20..c812e24b01 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Sofa.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Sofa.cs @@ -1,8 +1,11 @@ using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers { public sealed class Sofa : Identifiable { + [Attr] + public int SeatCount { get; set; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Table.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Table.cs index af649cc581..6d07ba65d6 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Table.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/Table.cs @@ -1,9 +1,11 @@ using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.RestrictedControllers { public sealed class Table : Identifiable { - + [Attr] + public int LegCount { get; set; } } } From 21b94c5f30738dd83905ffe1d3b377f575098364 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 5 Feb 2021 10:52:44 +0100 Subject: [PATCH 112/123] Moved integration tests for filter/include/page/sort/fields into QueryStrings subfolder and auto-adjusted namespaces. --- .../IntegrationTests/CompositeKeys/CompositeKeyTests.cs | 1 + .../IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs | 1 + .../ContentNegotiation/ContentTypeHeaderTests.cs | 1 + .../ControllerActionResults/ActionResultTests.cs | 1 + .../CustomRoutes/ApiControllerAttributeTests.cs | 1 + .../IntegrationTests/CustomRoutes/CustomRouteTests.cs | 1 + .../IntegrationTests/EagerLoading/EagerLoadingTests.cs | 1 + .../ExceptionHandling/ExceptionHandlerTests.cs | 1 + .../IntegrationTests/IdObfuscation/IdObfuscationTests.cs | 1 + .../ModelStateValidation/NoModelStateValidationTests.cs | 1 + .../NamingConventions/KebabCasingConventionStartup.cs | 1 + .../{ => QueryStrings}/Filtering/FilterDataTypeTests.cs | 3 ++- .../{ => QueryStrings}/Filtering/FilterDbContext.cs | 2 +- .../{ => QueryStrings}/Filtering/FilterDepthTests.cs | 2 +- .../{ => QueryStrings}/Filtering/FilterOperatorTests.cs | 3 ++- .../{ => QueryStrings}/Filtering/FilterTests.cs | 2 +- .../{ => QueryStrings}/Filtering/FilterableResource.cs | 2 +- .../Filtering/FilterableResourcesController.cs | 2 +- .../{ => QueryStrings}/Includes/IncludeTests.cs | 2 +- .../Pagination/PaginationWithTotalCountTests.cs | 2 +- .../Pagination/PaginationWithoutTotalCountTests.cs | 2 +- .../{ => QueryStrings}/Pagination/RangeValidationTests.cs | 2 +- .../Pagination/RangeValidationWithMaximumTests.cs | 2 +- .../IntegrationTests/QueryStrings/QueryStringTests.cs | 2 +- .../QueryStrings/SerializerDefaultValueHandlingTests.cs | 1 + .../QueryStrings/SerializerNullValueHandlingTests.cs | 1 + .../IntegrationTests/{ => QueryStrings}/Sorting/SortTests.cs | 2 +- .../{ => QueryStrings}/SparseFieldSets/ResourceCaptureStore.cs | 2 +- .../SparseFieldSets/ResultCapturingRepository.cs | 2 +- .../{ => QueryStrings}/SparseFieldSets/SparseFieldSetTests.cs | 2 +- .../IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs | 1 + .../Creating/CreateResourceWithClientGeneratedIdTests.cs | 1 + .../Creating/CreateResourceWithToManyRelationshipTests.cs | 1 + .../Creating/CreateResourceWithToOneRelationshipTests.cs | 1 + .../IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs | 1 + .../ReadWrite/Fetching/FetchRelationshipTests.cs | 1 + .../IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs | 1 + .../Updating/Relationships/AddToToManyRelationshipTests.cs | 1 + .../Relationships/RemoveFromToManyRelationshipTests.cs | 1 + .../Updating/Relationships/ReplaceToManyRelationshipTests.cs | 1 + .../Updating/Relationships/UpdateToOneRelationshipTests.cs | 1 + .../Updating/Resources/ReplaceToManyRelationshipTests.cs | 1 + .../ReadWrite/Updating/Resources/UpdateResourceTests.cs | 1 + .../Updating/Resources/UpdateToOneRelationshipTests.cs | 1 + .../RequiredRelationships/DefaultBehaviorTests.cs | 1 + .../ResourceConstructorInjection/ResourceInjectionTests.cs | 1 + .../ResourceDefinitionQueryCallbackTests.cs | 1 + .../IntegrationTests/ResourceHooks/ResourceHooksStartup.cs | 1 + .../IntegrationTests/ResourceInheritance/InheritanceTests.cs | 1 + .../RestrictedControllers/DisableQueryStringTests.cs | 1 + .../RestrictedControllers/HttpReadOnlyTests.cs | 1 + .../RestrictedControllers/NoHttpDeleteTests.cs | 1 + .../IntegrationTests/RestrictedControllers/NoHttpPatchTests.cs | 1 + .../IntegrationTests/RestrictedControllers/NoHttpPostTests.cs | 1 + .../IntegrationTests/Serialization/SerializationTests.cs | 1 + .../IntegrationTests/SoftDeletion/SoftDeletionTests.cs | 1 + .../IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs | 1 + .../IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs | 1 + test/JsonApiDotNetCoreExampleTests/Startups/TestableStartup.cs | 2 +- 59 files changed, 61 insertions(+), 18 deletions(-) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{ => QueryStrings}/Filtering/FilterDataTypeTests.cs (99%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{ => QueryStrings}/Filtering/FilterDbContext.cs (78%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{ => QueryStrings}/Filtering/FilterDepthTests.cs (99%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{ => QueryStrings}/Filtering/FilterOperatorTests.cs (99%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{ => QueryStrings}/Filtering/FilterTests.cs (98%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{ => QueryStrings}/Filtering/FilterableResource.cs (95%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{ => QueryStrings}/Filtering/FilterableResourcesController.cs (86%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{ => QueryStrings}/Includes/IncludeTests.cs (99%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{ => QueryStrings}/Pagination/PaginationWithTotalCountTests.cs (99%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{ => QueryStrings}/Pagination/PaginationWithoutTotalCountTests.cs (99%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{ => QueryStrings}/Pagination/RangeValidationTests.cs (98%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{ => QueryStrings}/Pagination/RangeValidationWithMaximumTests.cs (98%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{ => QueryStrings}/Sorting/SortTests.cs (99%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{ => QueryStrings}/SparseFieldSets/ResourceCaptureStore.cs (83%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{ => QueryStrings}/SparseFieldSets/ResultCapturingRepository.cs (94%) rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/{ => QueryStrings}/SparseFieldSets/SparseFieldSetTests.cs (99%) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs index c177137cd7..ad647062c3 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs @@ -5,6 +5,7 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs index 0169c316b5..4eafc2e40a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs index 14367717ad..790370f12e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs @@ -3,6 +3,7 @@ using FluentAssertions; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs index fa6ccebf05..3b346fb180 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs index 4a3237ee9e..f124fe6ae6 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs index 5028008eab..5978ae3b4c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs index 1dbe44d277..76d7d9a972 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs @@ -3,6 +3,7 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs index 09e946dc11..6b1dd941ed 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs @@ -5,6 +5,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs index ae8ab26b42..9b5bde276a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs index ca5d155cb6..f24da7a5d1 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs index 44fd3af114..d871dac25e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs @@ -1,4 +1,5 @@ using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Newtonsoft.Json.Serialization; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDataTypeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDataTypeTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs index 805fd8b513..e2b77af026 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDataTypeTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs @@ -7,11 +7,12 @@ using Humanizer; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Filtering +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings.Filtering { public sealed class FilterDataTypeTests : IClassFixture, FilterDbContext>> { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterDbContext.cs similarity index 78% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDbContext.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterDbContext.cs index 86db753075..ec0e971b39 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterDbContext.cs @@ -1,6 +1,6 @@ using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Filtering +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings.Filtering { public sealed class FilterDbContext : DbContext { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDepthTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDepthTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs index a0748bf943..c97d216000 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterDepthTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs @@ -13,7 +13,7 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Filtering +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings.Filtering { public sealed class FilterDepthTests : IClassFixture> { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs index 71a6f54f21..dbd6773f50 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterOperatorTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs @@ -8,11 +8,12 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Filtering +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings.Filtering { public sealed class FilterOperatorTests : IClassFixture, FilterDbContext>> { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs similarity index 98% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs index c60671a5f3..60da47cce0 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs @@ -10,7 +10,7 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Filtering +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings.Filtering { public sealed class FilterTests : IClassFixture> { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterableResource.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs similarity index 95% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterableResource.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs index be66eec1de..4be6b98e02 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterableResource.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Filtering +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings.Filtering { public sealed class FilterableResource : Identifiable { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterableResourcesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterableResourcesController.cs similarity index 86% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterableResourcesController.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterableResourcesController.cs index 7314d155d0..36f23a6c7b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Filtering/FilterableResourcesController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterableResourcesController.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Filtering +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings.Filtering { public sealed class FilterableResourcesController : JsonApiController { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs index 6afd1fd707..de09d47d3d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Includes/IncludeTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs @@ -14,7 +14,7 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Includes +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings.Includes { public sealed class IncludeTests : IClassFixture> { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs index 70147ee66a..3802fc76b0 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithTotalCountTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs @@ -13,7 +13,7 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Pagination +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings.Pagination { public sealed class PaginationWithTotalCountTests : IClassFixture> { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithoutTotalCountTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Pagination/PaginationWithoutTotalCountTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithoutTotalCountTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Pagination/PaginationWithoutTotalCountTests.cs index 453494acae..242378fe02 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/PaginationWithoutTotalCountTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Pagination/PaginationWithoutTotalCountTests.cs @@ -10,7 +10,7 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Pagination +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings.Pagination { public sealed class PaginationWithoutTotalCountTests : IClassFixture> { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs similarity index 98% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs index 6e76dae7c5..fa961ce523 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs @@ -10,7 +10,7 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Pagination +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings.Pagination { public sealed class RangeValidationTests : IClassFixture> { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationWithMaximumTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs similarity index 98% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationWithMaximumTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs index 6fe73f6df6..ea13c1643d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Pagination/RangeValidationWithMaximumTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs @@ -9,7 +9,7 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Pagination +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings.Pagination { public sealed class RangeValidationWithMaximumTests : IClassFixture> { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs index b3a5db84c8..1fc4c975d0 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/QueryStringTests.cs @@ -3,6 +3,7 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; @@ -13,7 +14,6 @@ public sealed class QueryStringTests : IClassFixture, QueryStringDbContext>> { private readonly ExampleIntegrationTestContext, QueryStringDbContext> _testContext; - private readonly QueryStringFakers _fakers = new QueryStringFakers(); public QueryStringTests(ExampleIntegrationTestContext, QueryStringDbContext> testContext) { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerDefaultValueHandlingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerDefaultValueHandlingTests.cs index 829cd5de4d..898b6b7ad4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerDefaultValueHandlingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerDefaultValueHandlingTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; using TestBuildingBlocks; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerNullValueHandlingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerNullValueHandlingTests.cs index aaa2029c6b..5ee4f490c8 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerNullValueHandlingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SerializerNullValueHandlingTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; using TestBuildingBlocks; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Sorting/SortTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Sorting/SortTests.cs index bf9b98f424..efb0219211 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Sorting/SortTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Sorting/SortTests.cs @@ -11,7 +11,7 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Sorting +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings.Sorting { public sealed class SortTests : IClassFixture> { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResourceCaptureStore.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SparseFieldSets/ResourceCaptureStore.cs similarity index 83% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResourceCaptureStore.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SparseFieldSets/ResourceCaptureStore.cs index a4cef25a9f..2082031663 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResourceCaptureStore.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SparseFieldSets/ResourceCaptureStore.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SparseFieldSets +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings.SparseFieldSets { public sealed class ResourceCaptureStore { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SparseFieldSets/ResultCapturingRepository.cs similarity index 94% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SparseFieldSets/ResultCapturingRepository.cs index 4387296f9f..1b0f8b7efc 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/ResultCapturingRepository.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SparseFieldSets/ResultCapturingRepository.cs @@ -7,7 +7,7 @@ using JsonApiDotNetCore.Resources; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SparseFieldSets +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings.SparseFieldSets { /// /// Enables sparse fieldset tests to verify which fields were (not) retrieved from the database. diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs index e478129b84..6f412dc994 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SparseFieldSets/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs @@ -14,7 +14,7 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SparseFieldSets +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings.SparseFieldSets { public sealed class SparseFieldSetTests : IClassFixture> { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs index 9c5d5e775b..4e961c5e17 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceTests.cs @@ -6,6 +6,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs index 86ad8fd335..a82338667a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -5,6 +5,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs index 558c375149..652ce88661 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs index ad8865d712..e9a00616fc 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToOneRelationshipTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs index 05f1ac71b7..3b49693fc3 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Deleting/DeleteResourceTests.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs index fc34b593d2..252539df21 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs index 57c49860c3..6042b9ded4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs index c8ac2c66ff..1d43cb3a59 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/AddToToManyRelationshipTests.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs index 7dbadd7717..550f020e73 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/RemoveFromToManyRelationshipTests.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs index bfdd768bd6..5140bbfeba 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/ReplaceToManyRelationshipTests.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs index e0f202b068..b86ec9b952 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Relationships/UpdateToOneRelationshipTests.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs index 163d6b9d5e..310d833ed5 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/ReplaceToManyRelationshipTests.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs index bc9f15e423..f4017c2492 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateResourceTests.cs @@ -6,6 +6,7 @@ using FluentAssertions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs index 7c4eb34229..87c84d4327 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ReadWrite/Updating/Resources/UpdateToOneRelationshipTests.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs index 0f8d4fdda9..9fda109f2e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RequiredRelationships/DefaultBehaviorTests.cs @@ -3,6 +3,7 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs index e381cdece2..92b0783b89 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceConstructorInjection/ResourceInjectionTests.cs @@ -4,6 +4,7 @@ using FluentAssertions.Common; using FluentAssertions.Extensions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs index 41e010ad5a..5c54c0e658 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs @@ -6,6 +6,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHooksStartup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHooksStartup.cs index fdc523810d..64685ba34e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHooksStartup.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceHooks/ResourceHooksStartup.cs @@ -1,4 +1,5 @@ using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs index 0ce3721116..602600b2b4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceInheritance/InheritanceTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceInheritance.Models; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs index 6f73c145da..323576c49d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/DisableQueryStringTests.cs @@ -3,6 +3,7 @@ using FluentAssertions; using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/HttpReadOnlyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/HttpReadOnlyTests.cs index 0895e7d8b4..b2725a9d18 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/HttpReadOnlyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/HttpReadOnlyTests.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpDeleteTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpDeleteTests.cs index 699d1fc7df..26a8036d80 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpDeleteTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpDeleteTests.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPatchTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPatchTests.cs index 9b2d7a3ea3..b3acd90b77 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPatchTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPatchTests.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPostTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPostTests.cs index 837a0a1eeb..4486093ca0 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPostTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RestrictedControllers/NoHttpPostTests.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationTests.cs index b57aebe571..76d0ccf298 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Serialization/SerializationTests.cs @@ -6,6 +6,7 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; using Newtonsoft.Json.Linq; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs index af26d97e67..2dcd98ea40 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs @@ -5,6 +5,7 @@ using FluentAssertions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs index 412a996f57..a4193f46b0 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs @@ -5,6 +5,7 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs index 8d13766ab9..a50dec9bc0 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ZeroKeys/ZeroAsKeyTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; diff --git a/test/JsonApiDotNetCoreExampleTests/Startups/TestableStartup.cs b/test/JsonApiDotNetCoreExampleTests/Startups/TestableStartup.cs index aaba661a65..3d07ea8187 100644 --- a/test/JsonApiDotNetCoreExampleTests/Startups/TestableStartup.cs +++ b/test/JsonApiDotNetCoreExampleTests/Startups/TestableStartup.cs @@ -8,7 +8,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Converters; -namespace JsonApiDotNetCoreExampleTests +namespace JsonApiDotNetCoreExampleTests.Startups { public class TestableStartup : EmptyStartup where TDbContext : DbContext From a044cf0f63515ddea94bde85fc3bada321668b5e Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 5 Feb 2021 10:54:04 +0100 Subject: [PATCH 113/123] Move types into separate files --- .../BaseToothbrushesController.cs | 63 +++++++++++++++++++ .../ToothbrushesController.cs | 54 ---------------- .../CallableResourceDefinition.cs | 5 -- .../ResourceDefinitions/IUserRolesService.cs | 7 +++ 4 files changed, 70 insertions(+), 59 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/BaseToothbrushesController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/IUserRolesService.cs diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/BaseToothbrushesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/BaseToothbrushesController.cs new file mode 100644 index 0000000000..4c7338405f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/BaseToothbrushesController.cs @@ -0,0 +1,63 @@ +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ControllerActionResults +{ + public abstract class BaseToothbrushesController : BaseJsonApiController + { + protected BaseToothbrushesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + + public override async Task GetAsync(int id, CancellationToken cancellationToken) + { + if (id == 11111111) + { + return NotFound(); + } + + if (id == 22222222) + { + return NotFound(new Error(HttpStatusCode.NotFound) + { + Title = "No toothbrush with that ID exists." + }); + } + + if (id == 33333333) + { + return Conflict("Something went wrong."); + } + + if (id == 44444444) + { + return Error(new Error(HttpStatusCode.BadGateway)); + } + + if (id == 55555555) + { + var errors = new[] + { + new Error(HttpStatusCode.PreconditionFailed), + new Error(HttpStatusCode.Unauthorized), + new Error(HttpStatusCode.ExpectationFailed) + { + Title = "This is not a very great request." + } + }; + return Error(errors); + } + + return await base.GetAsync(id, cancellationToken); + } + } +} \ No newline at end of file diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs index 8155d1ba3b..8cd4e1d646 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs @@ -1,9 +1,6 @@ -using System.Net; using System.Threading; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -24,55 +21,4 @@ public override Task GetAsync(int id, CancellationToken cancellat return base.GetAsync(id, cancellationToken); } } - - public abstract class BaseToothbrushesController : BaseJsonApiController - { - protected BaseToothbrushesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - - public override async Task GetAsync(int id, CancellationToken cancellationToken) - { - if (id == 11111111) - { - return NotFound(); - } - - if (id == 22222222) - { - return NotFound(new Error(HttpStatusCode.NotFound) - { - Title = "No toothbrush with that ID exists." - }); - } - - if (id == 33333333) - { - return Conflict("Something went wrong."); - } - - if (id == 44444444) - { - return Error(new Error(HttpStatusCode.BadGateway)); - } - - if (id == 55555555) - { - var errors = new[] - { - new Error(HttpStatusCode.PreconditionFailed), - new Error(HttpStatusCode.Unauthorized), - new Error(HttpStatusCode.ExpectationFailed) - { - Title = "This is not a very great request." - } - }; - return Error(errors); - } - - return await base.GetAsync(id, cancellationToken); - } - } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs index a3de6f4b0d..7cfb920377 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs @@ -11,11 +11,6 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions { - public interface IUserRolesService - { - bool AllowIncludeOwner { get; } - } - public sealed class CallableResourceDefinition : JsonApiResourceDefinition { private readonly IUserRolesService _userRolesService; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/IUserRolesService.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/IUserRolesService.cs new file mode 100644 index 0000000000..1343637bae --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/IUserRolesService.cs @@ -0,0 +1,7 @@ +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions +{ + public interface IUserRolesService + { + bool AllowIncludeOwner { get; } + } +} \ No newline at end of file From 78bc0c392d2157844d5f420bebe997704b65ea6c Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 5 Feb 2021 11:23:02 +0100 Subject: [PATCH 114/123] Moved unit tests for query string parameters into ExampleTests project, so they can share private models with integration tests for query strings. They are likely to evolve simultaneously, so I prefer not to make a copy of them. Renamed Blog to LegacyBlog in preparation for refactoring to make query string tests run against private models. LegacyBlog is going away at the end. --- ...Controller.cs => LegacyBlogsController.cs} | 6 +- .../Data/AppDbContext.cs | 2 +- .../Models/Article.cs | 2 +- .../Models/{Blog.cs => LegacyBlog.cs} | 2 +- .../Filtering/FilterDepthTests.cs | 40 ++++++------- .../QueryStrings/Includes/IncludeTests.cs | 18 +++--- .../PaginationWithTotalCountTests.cs | 58 +++++++++---------- .../QueryStrings/Sorting/SortTests.cs | 38 ++++++------ .../SparseFieldSets/SparseFieldSetTests.cs | 26 ++++----- .../QueryStringParameters/BaseParseTests.cs | 6 +- .../DefaultsParseTests.cs | 2 +- .../QueryStringParameters/FilterParseTests.cs | 10 ++-- .../IncludeParseTests.cs | 4 +- .../LegacyFilterParseTests.cs | 2 +- .../QueryStringParameters/NullsParseTests.cs | 2 +- .../PaginationParseTests.cs | 6 +- .../QueryStringParameters/SortParseTests.cs | 10 ++-- .../SparseFieldSetParseTests.cs | 2 +- 18 files changed, 118 insertions(+), 118 deletions(-) rename src/Examples/JsonApiDotNetCoreExample/Controllers/{BlogsController.cs => LegacyBlogsController.cs} (69%) rename src/Examples/JsonApiDotNetCoreExample/Models/{Blog.cs => LegacyBlog.cs} (89%) rename test/{ => JsonApiDotNetCoreExampleTests}/UnitTests/QueryStringParameters/BaseParseTests.cs (88%) rename test/{ => JsonApiDotNetCoreExampleTests}/UnitTests/QueryStringParameters/DefaultsParseTests.cs (98%) rename test/{ => JsonApiDotNetCoreExampleTests}/UnitTests/QueryStringParameters/FilterParseTests.cs (96%) rename test/{ => JsonApiDotNetCoreExampleTests}/UnitTests/QueryStringParameters/IncludeParseTests.cs (97%) rename test/{ => JsonApiDotNetCoreExampleTests}/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs (98%) rename test/{ => JsonApiDotNetCoreExampleTests}/UnitTests/QueryStringParameters/NullsParseTests.cs (98%) rename test/{ => JsonApiDotNetCoreExampleTests}/UnitTests/QueryStringParameters/PaginationParseTests.cs (98%) rename test/{ => JsonApiDotNetCoreExampleTests}/UnitTests/QueryStringParameters/SortParseTests.cs (95%) rename test/{ => JsonApiDotNetCoreExampleTests}/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs (98%) diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/BlogsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/LegacyBlogsController.cs similarity index 69% rename from src/Examples/JsonApiDotNetCoreExample/Controllers/BlogsController.cs rename to src/Examples/JsonApiDotNetCoreExample/Controllers/LegacyBlogsController.cs index 824b7a30a6..be3dd80540 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/BlogsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/LegacyBlogsController.cs @@ -6,12 +6,12 @@ namespace JsonApiDotNetCoreExample.Controllers { - public sealed class BlogsController : JsonApiController + public sealed class LegacyBlogsController : JsonApiController { - public BlogsController( + public LegacyBlogsController( IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) + IResourceService resourceService) : base(options, loggerFactory, resourceService) { } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index 2f3e590dac..08b6442593 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -10,7 +10,7 @@ public sealed class AppDbContext : DbContext public DbSet
Articles { get; set; } public DbSet AuthorDifferentDbContextName { get; set; } public DbSet Users { get; set; } - public DbSet Blogs { get; set; } + public DbSet Blogs { get; set; } public AppDbContext(DbContextOptions options) : base(options) { diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs index 455ed51039..0282f50fff 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Article.cs @@ -30,6 +30,6 @@ public sealed class Article : Identifiable public ICollection Revisions { get; set; } [HasOne] - public Blog Blog { get; set; } + public LegacyBlog Blog { get; set; } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Blog.cs b/src/Examples/JsonApiDotNetCoreExample/Models/LegacyBlog.cs similarity index 89% rename from src/Examples/JsonApiDotNetCoreExample/Models/Blog.cs rename to src/Examples/JsonApiDotNetCoreExample/Models/LegacyBlog.cs index 0330150ca3..8e38b9db3e 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Blog.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/LegacyBlog.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCoreExample.Models { - public sealed class Blog : Identifiable + public sealed class LegacyBlog : Identifiable { [Attr] public string Title { get; set; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs index c97d216000..c95d32a1a4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs @@ -101,7 +101,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_filter_in_secondary_resources() { // Arrange - var blog = new Blog + var blog = new LegacyBlog { Articles = new List
{ @@ -123,7 +123,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/blogs/{blog.StringId}/articles?filter=equals(caption,'Two')"; + var route = $"/api/v1/legacyBlogs/{blog.StringId}/articles?filter=equals(caption,'Two')"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -216,10 +216,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_filter_on_HasMany_relationship() { // Arrange - var blogs = new List + var blogs = new List { - new Blog(), - new Blog + new LegacyBlog(), + new LegacyBlog { Articles = new List
{ @@ -233,13 +233,13 @@ public async Task Can_filter_on_HasMany_relationship() await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); + await dbContext.ClearTableAsync(); dbContext.Blogs.AddRange(blogs); await dbContext.SaveChangesAsync(); }); - var route = "/api/v1/blogs?filter=greaterThan(count(articles),'0')"; + var route = "/api/v1/legacyBlogs?filter=greaterThan(count(articles),'0')"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -301,7 +301,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_filter_in_scope_of_HasMany_relationship() { // Arrange - var blog = new Blog + var blog = new LegacyBlog { Articles = new List
{ @@ -318,13 +318,13 @@ public async Task Can_filter_in_scope_of_HasMany_relationship() await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); + await dbContext.ClearTableAsync(); dbContext.Blogs.Add(blog); await dbContext.SaveChangesAsync(); }); - var route = "/api/v1/blogs?include=articles&filter[articles]=equals(caption,'Two')"; + var route = "/api/v1/legacyBlogs?include=articles&filter[articles]=equals(caption,'Two')"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -342,7 +342,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_filter_in_scope_of_HasMany_relationship_on_secondary_resource() { // Arrange - var blog = new Blog + var blog = new LegacyBlog { Owner = new Author { @@ -368,7 +368,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/blogs/{blog.StringId}/owner?include=articles&filter[articles]=equals(caption,'Two')"; + var route = $"/api/v1/legacyBlogs/{blog.StringId}/owner?include=articles&filter[articles]=equals(caption,'Two')"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -449,7 +449,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_filter_in_scope_of_relationship_chain() { // Arrange - var blog = new Blog + var blog = new LegacyBlog { Owner = new Author { @@ -470,13 +470,13 @@ public async Task Can_filter_in_scope_of_relationship_chain() await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); + await dbContext.ClearTableAsync(); dbContext.Blogs.Add(blog); await dbContext.SaveChangesAsync(); }); - var route = "/api/v1/blogs?include=owner.articles&filter[owner.articles]=equals(caption,'Two')"; + var route = "/api/v1/legacyBlogs?include=owner.articles&filter[owner.articles]=equals(caption,'Two')"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -595,10 +595,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_filter_in_multiple_scopes() { // Arrange - var blogs = new List + var blogs = new List { - new Blog(), - new Blog + new LegacyBlog(), + new LegacyBlog { Title = "Technology", Owner = new Author @@ -632,13 +632,13 @@ public async Task Can_filter_in_multiple_scopes() await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); + await dbContext.ClearTableAsync(); dbContext.Blogs.AddRange(blogs); await dbContext.SaveChangesAsync(); }); - var route = "/api/v1/blogs?include=owner.articles.revisions&" + + var route = "/api/v1/legacyBlogs?include=owner.articles.revisions&" + "filter=and(equals(title,'Technology'),has(owner.articles),equals(owner.lastName,'Smith'))&" + "filter[owner.articles]=equals(caption,'Two')&" + "filter[owner.articles.revisions]=greaterThan(publishTime,'2005-05-05')"; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs index de09d47d3d..3fdabbaa06 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs @@ -114,7 +114,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_include_in_secondary_resource() { // Arrange - var blog = new Blog + var blog = new LegacyBlog { Owner = new Author { @@ -136,7 +136,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/blogs/{blog.StringId}/owner?include=articles"; + var route = $"/api/v1/legacyBlogs/{blog.StringId}/owner?include=articles"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -158,7 +158,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_include_in_secondary_resources() { // Arrange - var blog = new Blog + var blog = new LegacyBlog { Articles = new List
{ @@ -180,7 +180,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/blogs/{blog.StringId}/articles?include=author"; + var route = $"/api/v1/legacyBlogs/{blog.StringId}/articles?include=author"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -434,7 +434,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_include_chain_of_HasMany_relationships() { // Arrange - var blog = new Blog + var blog = new LegacyBlog { Title = "Some", Articles = new List
@@ -460,7 +460,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/blogs/{blog.StringId}?include=articles.revisions"; + var route = $"/api/v1/legacyBlogs/{blog.StringId}?include=articles.revisions"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -843,7 +843,7 @@ public async Task Can_include_at_configured_maximum_inclusion_depth() var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); options.MaximumIncludeDepth = 1; - var blog = new Blog(); + var blog = new LegacyBlog(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -852,7 +852,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/blogs/{blog.StringId}/articles?include=author,revisions"; + var route = $"/api/v1/legacyBlogs/{blog.StringId}/articles?include=author,revisions"; // Act var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); @@ -868,7 +868,7 @@ public async Task Cannot_exceed_configured_maximum_inclusion_depth() var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); options.MaximumIncludeDepth = 1; - var route = "/api/v1/blogs/123/owner?include=articles.revisions"; + var route = "/api/v1/legacyBlogs/123/owner?include=articles.revisions"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs index 3802fc76b0..903be6f55a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs @@ -115,7 +115,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_paginate_in_secondary_resources() { // Arrange - var blog = new Blog + var blog = new LegacyBlog { Articles = new List
{ @@ -137,7 +137,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/blogs/{blog.StringId}/articles?page[number]=2&page[size]=1"; + var route = $"/api/v1/legacyBlogs/{blog.StringId}/articles?page[number]=2&page[size]=1"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -150,10 +150,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be("http://localhost" + route); - responseDocument.Links.First.Should().Be($"http://localhost/api/v1/blogs/{blog.StringId}/articles?page[size]=1"); + responseDocument.Links.First.Should().Be($"http://localhost/api/v1/legacyBlogs/{blog.StringId}/articles?page[size]=1"); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().Be(responseDocument.Links.First); - responseDocument.Links.Next.Should().Be($"http://localhost/api/v1/blogs/{blog.StringId}/articles?page[number]=3&page[size]=1"); + responseDocument.Links.Next.Should().Be($"http://localhost/api/v1/legacyBlogs/{blog.StringId}/articles?page[number]=3&page[size]=1"); } [Fact] @@ -191,9 +191,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_paginate_in_scope_of_HasMany_relationship() { // Arrange - var blogs = new List + var blogs = new List { - new Blog + new LegacyBlog { Articles = new List
{ @@ -207,7 +207,7 @@ public async Task Can_paginate_in_scope_of_HasMany_relationship() } } }, - new Blog + new LegacyBlog { Articles = new List
{ @@ -221,18 +221,18 @@ public async Task Can_paginate_in_scope_of_HasMany_relationship() } } }, - new Blog() + new LegacyBlog() }; await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); + await dbContext.ClearTableAsync(); dbContext.Blogs.AddRange(blogs); await dbContext.SaveChangesAsync(); }); - var route = "/api/v1/blogs?include=articles&page[number]=articles:2&page[size]=2,articles:1"; + var route = "/api/v1/legacyBlogs?include=articles&page[number]=articles:2&page[size]=2,articles:1"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -248,8 +248,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be("http://localhost" + route); - responseDocument.Links.First.Should().Be("http://localhost/api/v1/blogs?include=articles&page[size]=2,articles:1"); - responseDocument.Links.Last.Should().Be("http://localhost/api/v1/blogs?include=articles&page[number]=2&page[size]=2,articles:1"); + responseDocument.Links.First.Should().Be("http://localhost/api/v1/legacyBlogs?include=articles&page[size]=2,articles:1"); + responseDocument.Links.Last.Should().Be("http://localhost/api/v1/legacyBlogs?include=articles&page[number]=2&page[size]=2,articles:1"); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().Be(responseDocument.Links.Last); } @@ -258,7 +258,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_paginate_in_scope_of_HasMany_relationship_on_secondary_resource() { // Arrange - var blog = new Blog + var blog = new LegacyBlog { Owner = new Author { @@ -284,7 +284,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/blogs/{blog.StringId}/owner?include=articles&page[number]=articles:2&page[size]=articles:1"; + var route = $"/api/v1/legacyBlogs/{blog.StringId}/owner?include=articles&page[number]=articles:2&page[size]=articles:1"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -308,7 +308,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_paginate_HasMany_relationship_on_relationship_endpoint() { // Arrange - var blog = new Blog + var blog = new LegacyBlog { Articles = new List
{ @@ -330,7 +330,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/blogs/{blog.StringId}/relationships/articles?page[number]=2&page[size]=1"; + var route = $"/api/v1/legacyBlogs/{blog.StringId}/relationships/articles?page[number]=2&page[size]=1"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -343,7 +343,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be("http://localhost" + route); - responseDocument.Links.First.Should().Be($"http://localhost/api/v1/blogs/{blog.StringId}/relationships/articles?page[size]=1"); + responseDocument.Links.First.Should().Be($"http://localhost/api/v1/legacyBlogs/{blog.StringId}/relationships/articles?page[size]=1"); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().Be(responseDocument.Links.First); responseDocument.Links.Next.Should().BeNull(); @@ -491,13 +491,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_paginate_in_multiple_scopes() { // Arrange - var blogs = new List + var blogs = new List { - new Blog + new LegacyBlog { Title = "Cooking" }, - new Blog + new LegacyBlog { Title = "Technology", Owner = new Author @@ -531,13 +531,13 @@ public async Task Can_paginate_in_multiple_scopes() await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); + await dbContext.ClearTableAsync(); dbContext.Blogs.AddRange(blogs); await dbContext.SaveChangesAsync(); }); - var route = "/api/v1/blogs?include=owner.articles.revisions&" + + var route = "/api/v1/legacyBlogs?include=owner.articles.revisions&" + "page[size]=1,owner.articles:1,owner.articles.revisions:1&" + "page[number]=2,owner.articles:2,owner.articles.revisions:2"; @@ -557,8 +557,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be("http://localhost" + route); - responseDocument.Links.First.Should().Be("http://localhost/api/v1/blogs?include=owner.articles.revisions&page[size]=1,owner.articles:1,owner.articles.revisions:1"); - responseDocument.Links.Last.Should().Be("http://localhost/api/v1/blogs?include=owner.articles.revisions&page[size]=1,owner.articles:1,owner.articles.revisions:1&page[number]=2"); + responseDocument.Links.First.Should().Be("http://localhost/api/v1/legacyBlogs?include=owner.articles.revisions&page[size]=1,owner.articles:1,owner.articles.revisions:1"); + responseDocument.Links.Last.Should().Be("http://localhost/api/v1/legacyBlogs?include=owner.articles.revisions&page[size]=1,owner.articles:1,owner.articles.revisions:1&page[number]=2"); responseDocument.Links.Prev.Should().Be(responseDocument.Links.First); responseDocument.Links.Next.Should().BeNull(); } @@ -608,7 +608,7 @@ public async Task Uses_default_page_number_and_size() var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); options.DefaultPageSize = new PageSize(2); - var blog = new Blog + var blog = new LegacyBlog { Articles = new List
{ @@ -634,7 +634,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/blogs/{blog.StringId}/articles"; + var route = $"/api/v1/legacyBlogs/{blog.StringId}/articles"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -651,7 +651,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.First.Should().Be(responseDocument.Links.Self); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().Be($"http://localhost/api/v1/blogs/{blog.StringId}/articles?page[number]=2"); + responseDocument.Links.Next.Should().Be($"http://localhost/api/v1/legacyBlogs/{blog.StringId}/articles?page[number]=2"); } [Fact] @@ -661,7 +661,7 @@ public async Task Returns_all_resources_when_paging_is_disabled() var options = (JsonApiOptions) _testContext.Factory.Services.GetRequiredService(); options.DefaultPageSize = null; - var blog = new Blog + var blog = new LegacyBlog { Articles = new List
() }; @@ -681,7 +681,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/blogs/{blog.StringId}/articles"; + var route = $"/api/v1/legacyBlogs/{blog.StringId}/articles"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Sorting/SortTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Sorting/SortTests.cs index efb0219211..594da043b3 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Sorting/SortTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Sorting/SortTests.cs @@ -91,7 +91,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_sort_in_secondary_resources() { // Arrange - var blog = new Blog + var blog = new LegacyBlog { Articles = new List
{ @@ -108,7 +108,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/blogs/{blog.StringId}/articles?sort=caption"; + var route = $"/api/v1/legacyBlogs/{blog.StringId}/articles?sort=caption"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -157,9 +157,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_sort_on_HasMany_relationship() { // Arrange - var blogs = new List + var blogs = new List { - new Blog + new LegacyBlog { Articles = new List
{ @@ -173,7 +173,7 @@ public async Task Can_sort_on_HasMany_relationship() } } }, - new Blog + new LegacyBlog { Articles = new List
{ @@ -187,13 +187,13 @@ public async Task Can_sort_on_HasMany_relationship() await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); + await dbContext.ClearTableAsync(); dbContext.Blogs.AddRange(blogs); await dbContext.SaveChangesAsync(); }); - var route = "/api/v1/blogs?sort=count(articles)"; + var route = "/api/v1/legacyBlogs?sort=count(articles)"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -310,7 +310,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_sort_in_scope_of_HasMany_relationship_on_secondary_resource() { // Arrange - var blog = new Blog + var blog = new LegacyBlog { Owner = new Author { @@ -331,7 +331,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/blogs/{blog.StringId}/owner?include=articles&sort[articles]=caption"; + var route = $"/api/v1/legacyBlogs/{blog.StringId}/owner?include=articles&sort[articles]=caption"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -406,9 +406,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_sort_on_multiple_fields_in_multiple_scopes() { // Arrange - var blogs = new List + var blogs = new List { - new Blog + new LegacyBlog { Title = "Z", Articles = new List
@@ -448,7 +448,7 @@ public async Task Can_sort_on_multiple_fields_in_multiple_scopes() } } }, - new Blog + new LegacyBlog { Title = "Y" } @@ -456,13 +456,13 @@ public async Task Can_sort_on_multiple_fields_in_multiple_scopes() await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); + await dbContext.ClearTableAsync(); dbContext.Blogs.AddRange(blogs); await dbContext.SaveChangesAsync(); }); - var route = "/api/v1/blogs?include=articles.revisions&sort=title&sort[articles]=caption,url&sort[articles.revisions]=-publishTime"; + var route = "/api/v1/legacyBlogs?include=articles.revisions&sort=title&sort[articles]=caption,url&sort[articles.revisions]=-publishTime"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -547,13 +547,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_sort_in_multiple_scopes() { // Arrange - var blogs = new List + var blogs = new List { - new Blog + new LegacyBlog { Title = "Cooking" }, - new Blog + new LegacyBlog { Title = "Technology", Owner = new Author @@ -587,13 +587,13 @@ public async Task Can_sort_in_multiple_scopes() await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); + await dbContext.ClearTableAsync(); dbContext.Blogs.AddRange(blogs); await dbContext.SaveChangesAsync(); }); - var route = "/api/v1/blogs?include=owner.articles.revisions&" + + var route = "/api/v1/legacyBlogs?include=owner.articles.revisions&" + "sort=-title&" + "sort[owner.articles]=-caption&" + "sort[owner.articles.revisions]=-publishTime"; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs index 6f412dc994..092deca1f7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/SparseFieldSets/SparseFieldSetTests.cs @@ -29,7 +29,7 @@ public SparseFieldSetTests(ExampleIntegrationTestContext { services.AddSingleton(); - services.AddResourceRepository>(); + services.AddResourceRepository>(); services.AddResourceRepository>(); services.AddResourceRepository>(); services.AddResourceRepository>(); @@ -212,7 +212,7 @@ public async Task Can_select_fields_in_secondary_resources() var store = _testContext.Factory.Services.GetRequiredService(); store.Clear(); - var blog = new Blog + var blog = new LegacyBlog { Title = "Some", Articles = new List
@@ -232,7 +232,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/blogs/{blog.StringId}/articles?fields[articles]=caption,tags"; + var route = $"/api/v1/legacyBlogs/{blog.StringId}/articles?fields[articles]=caption,tags"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -249,7 +249,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.ManyData[0].Relationships["tags"].Links.Self.Should().NotBeNull(); responseDocument.ManyData[0].Relationships["tags"].Links.Related.Should().NotBeNull(); - var blogCaptured = (Blog)store.Resources.Should().ContainSingle(x => x is Blog).And.Subject.Single(); + var blogCaptured = (LegacyBlog)store.Resources.Should().ContainSingle(x => x is LegacyBlog).And.Subject.Single(); blogCaptured.Id.Should().Be(blog.Id); blogCaptured.Title.Should().BeNull(); @@ -379,7 +379,7 @@ public async Task Can_select_fields_of_HasMany_relationship_on_secondary_resourc var store = _testContext.Factory.Services.GetRequiredService(); store.Clear(); - var blog = new Blog + var blog = new LegacyBlog { Owner = new Author { @@ -402,7 +402,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/blogs/{blog.StringId}/owner?include=articles&fields[articles]=caption,revisions"; + var route = $"/api/v1/legacyBlogs/{blog.StringId}/owner?include=articles&fields[articles]=caption,revisions"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -426,7 +426,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[0].Relationships["revisions"].Links.Self.Should().NotBeNull(); responseDocument.Included[0].Relationships["revisions"].Links.Related.Should().NotBeNull(); - var blogCaptured = (Blog) store.Resources.Should().ContainSingle(x => x is Blog).And.Subject.Single(); + var blogCaptured = (LegacyBlog) store.Resources.Should().ContainSingle(x => x is LegacyBlog).And.Subject.Single(); blogCaptured.Id.Should().Be(blog.Id); blogCaptured.Owner.Should().NotBeNull(); blogCaptured.Owner.LastName.Should().Be(blog.Owner.LastName); @@ -500,7 +500,7 @@ public async Task Can_select_attributes_in_multiple_resource_types() var store = _testContext.Factory.Services.GetRequiredService(); store.Clear(); - var blog = new Blog + var blog = new LegacyBlog { Title = "Technology", CompanyName = "Contoso", @@ -527,7 +527,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/blogs/{blog.StringId}?include=owner.articles&fields[blogs]=title&fields[authors]=firstName,lastName&fields[articles]=caption"; + var route = $"/api/v1/legacyBlogs/{blog.StringId}?include=owner.articles&fields[legacyBlogs]=title&fields[authors]=firstName,lastName&fields[articles]=caption"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -554,7 +554,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[1].Attributes["caption"].Should().Be(blog.Owner.Articles[0].Caption); responseDocument.Included[1].Relationships.Should().BeNull(); - var blogCaptured = (Blog) store.Resources.Should().ContainSingle(x => x is Blog).And.Subject.Single(); + var blogCaptured = (LegacyBlog) store.Resources.Should().ContainSingle(x => x is LegacyBlog).And.Subject.Single(); blogCaptured.Id.Should().Be(blog.Id); blogCaptured.Title.Should().Be(blog.Title); blogCaptured.CompanyName.Should().BeNull(); @@ -575,7 +575,7 @@ public async Task Can_select_only_top_level_fields_with_multiple_includes() var store = _testContext.Factory.Services.GetRequiredService(); store.Clear(); - var blog = new Blog + var blog = new LegacyBlog { Title = "Technology", CompanyName = "Contoso", @@ -602,7 +602,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/blogs/{blog.StringId}?include=owner.articles&fields[blogs]=title,owner"; + var route = $"/api/v1/legacyBlogs/{blog.StringId}?include=owner.articles&fields[legacyBlogs]=title,owner"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -637,7 +637,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[1].Relationships["tags"].Links.Self.Should().NotBeNull(); responseDocument.Included[1].Relationships["tags"].Links.Related.Should().NotBeNull(); - var blogCaptured = (Blog) store.Resources.Should().ContainSingle(x => x is Blog).And.Subject.Single(); + var blogCaptured = (LegacyBlog) store.Resources.Should().ContainSingle(x => x is LegacyBlog).And.Subject.Single(); blogCaptured.Id.Should().Be(blog.Id); blogCaptured.Title.Should().Be(blog.Title); blogCaptured.CompanyName.Should().BeNull(); diff --git a/test/UnitTests/QueryStringParameters/BaseParseTests.cs b/test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/BaseParseTests.cs similarity index 88% rename from test/UnitTests/QueryStringParameters/BaseParseTests.cs rename to test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/BaseParseTests.cs index 3952d9af61..7c0bde044a 100644 --- a/test/UnitTests/QueryStringParameters/BaseParseTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/BaseParseTests.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCoreExample.Models; using Microsoft.Extensions.Logging.Abstractions; -namespace UnitTests.QueryStringParameters +namespace JsonApiDotNetCoreExampleTests.UnitTests.QueryStringParameters { public abstract class BaseParseTests { @@ -16,7 +16,7 @@ protected BaseParseTests() Options = new JsonApiOptions(); ResourceGraph = new ResourceGraphBuilder(Options, NullLoggerFactory.Instance) - .Add() + .Add() .Add
() .Add() .Add
() @@ -27,7 +27,7 @@ protected BaseParseTests() Request = new JsonApiRequest { - PrimaryResource = ResourceGraph.GetResourceContext(), + PrimaryResource = ResourceGraph.GetResourceContext(), IsCollection = true }; } diff --git a/test/UnitTests/QueryStringParameters/DefaultsParseTests.cs b/test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/DefaultsParseTests.cs similarity index 98% rename from test/UnitTests/QueryStringParameters/DefaultsParseTests.cs rename to test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/DefaultsParseTests.cs index 0a6e88449c..81383d00b7 100644 --- a/test/UnitTests/QueryStringParameters/DefaultsParseTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/DefaultsParseTests.cs @@ -9,7 +9,7 @@ using Newtonsoft.Json; using Xunit; -namespace UnitTests.QueryStringParameters +namespace JsonApiDotNetCoreExampleTests.UnitTests.QueryStringParameters { public sealed class DefaultsParseTests { diff --git a/test/UnitTests/QueryStringParameters/FilterParseTests.cs b/test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/FilterParseTests.cs similarity index 96% rename from test/UnitTests/QueryStringParameters/FilterParseTests.cs rename to test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/FilterParseTests.cs index 7ebdfd5a0c..fe7e364ff8 100644 --- a/test/UnitTests/QueryStringParameters/FilterParseTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/FilterParseTests.cs @@ -10,7 +10,7 @@ using JsonApiDotNetCore.Resources; using Xunit; -namespace UnitTests.QueryStringParameters +namespace JsonApiDotNetCoreExampleTests.UnitTests.QueryStringParameters { public sealed class FilterParseTests : BaseParseTests { @@ -55,7 +55,7 @@ public void Reader_Is_Enabled(StandardQueryStringParameters parametersDisabled, [Theory] [InlineData("filter[", "equals(caption,'some')", "Field name expected.")] - [InlineData("filter[caption]", "equals(url,'some')", "Relationship 'caption' does not exist on resource 'blogs'.")] + [InlineData("filter[caption]", "equals(url,'some')", "Relationship 'caption' does not exist on resource 'legacyBlogs'.")] [InlineData("filter[articles.caption]", "equals(firstName,'some')", "Relationship 'caption' in 'articles.caption' does not exist on resource 'articles'.")] [InlineData("filter[articles.author]", "equals(firstName,'some')", "Relationship 'author' in 'articles.author' must be a to-many relationship on resource 'articles'.")] [InlineData("filter[articles.revisions.author]", "equals(firstName,'some')", "Relationship 'author' in 'articles.revisions.author' must be a to-many relationship on resource 'revisions'.")] @@ -70,14 +70,14 @@ public void Reader_Is_Enabled(StandardQueryStringParameters parametersDisabled, [InlineData("filter", "equals(count(articles),", "Count function, value between quotes, null or field name expected.")] [InlineData("filter", "equals(title,')", "' expected.")] [InlineData("filter", "equals(title,null", ") expected.")] - [InlineData("filter", "equals(null", "Field 'null' does not exist on resource 'blogs'.")] + [InlineData("filter", "equals(null", "Field 'null' does not exist on resource 'legacyBlogs'.")] [InlineData("filter", "equals(title,(", "Count function, value between quotes, null or field name expected.")] - [InlineData("filter", "equals(has(articles),'true')", "Field 'has' does not exist on resource 'blogs'.")] + [InlineData("filter", "equals(has(articles),'true')", "Field 'has' does not exist on resource 'legacyBlogs'.")] [InlineData("filter", "contains)", "( expected.")] [InlineData("filter", "contains(title,'a','b')", ") expected.")] [InlineData("filter", "contains(title,null)", "Value between quotes expected.")] [InlineData("filter[articles]", "contains(author,null)", "Attribute 'author' does not exist on resource 'articles'.")] - [InlineData("filter", "any(null,'a','b')", "Attribute 'null' does not exist on resource 'blogs'.")] + [InlineData("filter", "any(null,'a','b')", "Attribute 'null' does not exist on resource 'legacyBlogs'.")] [InlineData("filter", "any('a','b','c')", "Field name expected.")] [InlineData("filter", "any(title,'b','c',)", "Value between quotes expected.")] [InlineData("filter", "any(title,'b')", ", expected.")] diff --git a/test/UnitTests/QueryStringParameters/IncludeParseTests.cs b/test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/IncludeParseTests.cs similarity index 97% rename from test/UnitTests/QueryStringParameters/IncludeParseTests.cs rename to test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/IncludeParseTests.cs index 281dc92cf4..3a1cee748a 100644 --- a/test/UnitTests/QueryStringParameters/IncludeParseTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/IncludeParseTests.cs @@ -9,7 +9,7 @@ using JsonApiDotNetCore.QueryStrings.Internal; using Xunit; -namespace UnitTests.QueryStringParameters +namespace JsonApiDotNetCoreExampleTests.UnitTests.QueryStringParameters { public sealed class IncludeParseTests : BaseParseTests { @@ -53,7 +53,7 @@ public void Reader_Is_Enabled(StandardQueryStringParameters parametersDisabled, [InlineData("includes", ",", "Relationship name expected.")] [InlineData("includes", "articles,", "Relationship name expected.")] [InlineData("includes", "articles[", ", expected.")] - [InlineData("includes", "title", "Relationship 'title' does not exist on resource 'blogs'.")] + [InlineData("includes", "title", "Relationship 'title' does not exist on resource 'legacyBlogs'.")] [InlineData("includes", "articles.revisions.publishTime,", "Relationship 'publishTime' in 'articles.revisions.publishTime' does not exist on resource 'revisions'.")] public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) { diff --git a/test/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs b/test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs similarity index 98% rename from test/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs rename to test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs index a5f9e3c6bb..67ea22c117 100644 --- a/test/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/LegacyFilterParseTests.cs @@ -9,7 +9,7 @@ using JsonApiDotNetCoreExample.Models; using Xunit; -namespace UnitTests.QueryStringParameters +namespace JsonApiDotNetCoreExampleTests.UnitTests.QueryStringParameters { public sealed class LegacyFilterParseTests : BaseParseTests { diff --git a/test/UnitTests/QueryStringParameters/NullsParseTests.cs b/test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/NullsParseTests.cs similarity index 98% rename from test/UnitTests/QueryStringParameters/NullsParseTests.cs rename to test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/NullsParseTests.cs index 009950c644..20ab2d4eb8 100644 --- a/test/UnitTests/QueryStringParameters/NullsParseTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/NullsParseTests.cs @@ -9,7 +9,7 @@ using Newtonsoft.Json; using Xunit; -namespace UnitTests.QueryStringParameters +namespace JsonApiDotNetCoreExampleTests.UnitTests.QueryStringParameters { public sealed class NullsParseTests { diff --git a/test/UnitTests/QueryStringParameters/PaginationParseTests.cs b/test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/PaginationParseTests.cs similarity index 98% rename from test/UnitTests/QueryStringParameters/PaginationParseTests.cs rename to test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/PaginationParseTests.cs index c70a38f814..497496573e 100644 --- a/test/UnitTests/QueryStringParameters/PaginationParseTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/PaginationParseTests.cs @@ -9,7 +9,7 @@ using JsonApiDotNetCore.QueryStrings.Internal; using Xunit; -namespace UnitTests.QueryStringParameters +namespace JsonApiDotNetCoreExampleTests.UnitTests.QueryStringParameters { public sealed class PaginationParseTests : BaseParseTests { @@ -66,7 +66,7 @@ public void Reader_Is_Enabled(StandardQueryStringParameters parametersDisabled, [InlineData("articles.id", "Relationship 'id' in 'articles.id' does not exist on resource 'articles'.")] [InlineData("articles.tags.id", "Relationship 'id' in 'articles.tags.id' does not exist on resource 'tags'.")] [InlineData("articles.author", "Relationship 'author' in 'articles.author' must be a to-many relationship on resource 'articles'.")] - [InlineData("something", "Relationship 'something' does not exist on resource 'blogs'.")] + [InlineData("something", "Relationship 'something' does not exist on resource 'legacyBlogs'.")] public void Reader_Read_Page_Number_Fails(string parameterValue, string errorMessage) { // Act @@ -99,7 +99,7 @@ public void Reader_Read_Page_Number_Fails(string parameterValue, string errorMes [InlineData("articles.id", "Relationship 'id' in 'articles.id' does not exist on resource 'articles'.")] [InlineData("articles.tags.id", "Relationship 'id' in 'articles.tags.id' does not exist on resource 'tags'.")] [InlineData("articles.author", "Relationship 'author' in 'articles.author' must be a to-many relationship on resource 'articles'.")] - [InlineData("something", "Relationship 'something' does not exist on resource 'blogs'.")] + [InlineData("something", "Relationship 'something' does not exist on resource 'legacyBlogs'.")] public void Reader_Read_Page_Size_Fails(string parameterValue, string errorMessage) { // Act diff --git a/test/UnitTests/QueryStringParameters/SortParseTests.cs b/test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/SortParseTests.cs similarity index 95% rename from test/UnitTests/QueryStringParameters/SortParseTests.cs rename to test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/SortParseTests.cs index 6fce86f153..60e52b45b5 100644 --- a/test/UnitTests/QueryStringParameters/SortParseTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/SortParseTests.cs @@ -8,7 +8,7 @@ using JsonApiDotNetCore.QueryStrings.Internal; using Xunit; -namespace UnitTests.QueryStringParameters +namespace JsonApiDotNetCoreExampleTests.UnitTests.QueryStringParameters { public sealed class SortParseTests : BaseParseTests { @@ -51,12 +51,12 @@ public void Reader_Is_Enabled(StandardQueryStringParameters parametersDisabled, [Theory] [InlineData("sort[", "id", "Field name expected.")] - [InlineData("sort[abc.def]", "id", "Relationship 'abc' in 'abc.def' does not exist on resource 'blogs'.")] + [InlineData("sort[abc.def]", "id", "Relationship 'abc' in 'abc.def' does not exist on resource 'legacyBlogs'.")] [InlineData("sort[articles.author]", "id", "Relationship 'author' in 'articles.author' must be a to-many relationship on resource 'articles'.")] [InlineData("sort", "", "-, count function or field name expected.")] [InlineData("sort", " ", "Unexpected whitespace.")] [InlineData("sort", "-", "Count function or field name expected.")] - [InlineData("sort", "abc", "Attribute 'abc' does not exist on resource 'blogs'.")] + [InlineData("sort", "abc", "Attribute 'abc' does not exist on resource 'legacyBlogs'.")] [InlineData("sort[articles]", "author", "Attribute 'author' does not exist on resource 'articles'.")] [InlineData("sort[articles]", "author.livingAddress", "Attribute 'livingAddress' in 'author.livingAddress' does not exist on resource 'authors'.")] [InlineData("sort", "-count", "( expected.")] @@ -64,8 +64,8 @@ public void Reader_Is_Enabled(StandardQueryStringParameters parametersDisabled, [InlineData("sort", "count(articles", ") expected.")] [InlineData("sort", "count(", "Field name expected.")] [InlineData("sort", "count(-abc)", "Field name expected.")] - [InlineData("sort", "count(abc)", "Relationship 'abc' does not exist on resource 'blogs'.")] - [InlineData("sort", "count(id)", "Relationship 'id' does not exist on resource 'blogs'.")] + [InlineData("sort", "count(abc)", "Relationship 'abc' does not exist on resource 'legacyBlogs'.")] + [InlineData("sort", "count(id)", "Relationship 'id' does not exist on resource 'legacyBlogs'.")] [InlineData("sort[articles]", "count(author)", "Relationship 'author' must be a to-many relationship on resource 'articles'.")] [InlineData("sort[articles]", "caption,", "-, count function or field name expected.")] [InlineData("sort[articles]", "caption:", ", expected.")] diff --git a/test/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs b/test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs similarity index 98% rename from test/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs rename to test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs index fac44d731d..1e7f911c12 100644 --- a/test/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/UnitTests/QueryStringParameters/SparseFieldSetParseTests.cs @@ -8,7 +8,7 @@ using JsonApiDotNetCore.QueryStrings.Internal; using Xunit; -namespace UnitTests.QueryStringParameters +namespace JsonApiDotNetCoreExampleTests.UnitTests.QueryStringParameters { public sealed class SparseFieldSetParseTests : BaseParseTests { From c2da909ad26369e63bd41662b359c905ed71768f Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 5 Feb 2021 23:13:32 +0100 Subject: [PATCH 115/123] Refactored query string integration tests to use private models. Changes: LegacyBlog -> Blog Title -> Title CompanyName -> PlatformName HasMany Articles -> Posts HasOne Owner -> Owner Article -> BlogPost Caption -> Caption Url -> Url HasOne Author -> Author + HasOne Reviewer HasManyThrough Tags -> Labels HasMany Revisions -> Comments HasOne Blog -> Parent Tag -> Label Name -> Name Color -> Color HasManyThrough Articles -> Posts Revision -> Comment + Text PublishTime -> CreatedAt HasOne Author -> Author HasOne Article -> Parent Author -> WebAccount FirstName/LastName -> UserName/DisplayName + Password DateOfBirth -> DateOfBirth BusinessEmail -> EmailAddress LivingAddress -> Preferences HasMany Articles -> Posts Address -> AccountPreferences + UseDarkTheme --- .../GettingStarted/Data/SampleDbContext.cs | 4 +- .../Data/AppDbContext.cs | 26 +- .../CompositeKeys/CompositeDbContext.cs | 8 +- .../Meta/ResourceMetaTests.cs | 8 +- .../QueryStrings/AccountPreferences.cs | 11 + .../IntegrationTests/QueryStrings/Blog.cs | 24 + .../IntegrationTests/QueryStrings/BlogPost.cs | 33 + .../QueryStrings/BlogPostLabel.cs | 11 + .../QueryStrings/BlogPostsController.cs | 16 + .../QueryStrings/BlogsController.cs | 16 + .../IntegrationTests/QueryStrings/Comment.cs | 21 + .../QueryStrings/CommentsController.cs | 16 + .../Filtering/FilterDataTypeTests.cs | 16 +- .../Filtering/FilterDepthTests.cs | 434 ++++-------- .../Filtering/FilterOperatorTests.cs | 19 +- .../QueryStrings/Filtering/FilterTests.cs | 41 +- .../QueryStrings/Includes/IncludeTests.cs | 647 +++++++----------- .../IntegrationTests/QueryStrings/Label.cs | 21 + .../QueryStrings/LabelColor.cs | 9 + .../PaginationWithTotalCountTests.cs | 420 ++++-------- .../PaginationWithoutTotalCountTests.cs | 87 ++- .../Pagination/RangeValidationTests.cs | 33 +- .../RangeValidationWithMaximumTests.cs | 24 +- .../QueryStrings/QueryStringDbContext.cs | 16 + .../QueryStrings/QueryStringFakers.cs | 44 ++ .../QueryStrings/Sorting/SortTests.cs | 538 ++++++--------- .../SparseFieldSets/SparseFieldSetTests.cs | 517 ++++++-------- .../QueryStrings/WebAccount.cs | 31 + .../QueryStrings/WebAccountsController.cs | 16 + .../InheritanceDbContext.cs | 10 +- 30 files changed, 1276 insertions(+), 1841 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/AccountPreferences.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Blog.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/BlogPost.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/BlogPostLabel.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/BlogPostsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/BlogsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Comment.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/CommentsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Label.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/LabelColor.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/WebAccount.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/WebAccountsController.cs diff --git a/src/Examples/GettingStarted/Data/SampleDbContext.cs b/src/Examples/GettingStarted/Data/SampleDbContext.cs index b58223da50..95781423b6 100644 --- a/src/Examples/GettingStarted/Data/SampleDbContext.cs +++ b/src/Examples/GettingStarted/Data/SampleDbContext.cs @@ -12,9 +12,9 @@ public SampleDbContext(DbContextOptions options) { } - protected override void OnModelCreating(ModelBuilder modelBuilder) + protected override void OnModelCreating(ModelBuilder builder) { - modelBuilder.Entity(); + builder.Entity(); } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index 08b6442593..614f6646bc 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -16,53 +16,53 @@ public AppDbContext(DbContextOptions options) : base(options) { } - protected override void OnModelCreating(ModelBuilder modelBuilder) + protected override void OnModelCreating(ModelBuilder builder) { - modelBuilder.Entity().HasBaseType(); + builder.Entity().HasBaseType(); - modelBuilder.Entity() + builder.Entity() .Property(t => t.CreatedDate).HasDefaultValueSql("CURRENT_TIMESTAMP").IsRequired(); - modelBuilder.Entity() + builder.Entity() .HasOne(t => t.Assignee) .WithMany(p => p.AssignedTodoItems); - modelBuilder.Entity() + builder.Entity() .HasOne(t => t.Owner) .WithMany(p => p.TodoItems); - modelBuilder.Entity() + builder.Entity() .HasKey(bc => new {bc.ArticleId, bc.TagId}); - modelBuilder.Entity() + builder.Entity() .HasKey(bc => new {bc.ArticleId, bc.TagId}); - modelBuilder.Entity() + builder.Entity() .HasOne(t => t.StakeHolderTodoItem) .WithMany(t => t.StakeHolders) .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() + builder.Entity() .HasMany(t => t.ChildrenTodos) .WithOne(t => t.ParentTodo); - modelBuilder.Entity() + builder.Entity() .HasOne(p => p.Person) .WithOne(p => p.Passport) .HasForeignKey("PassportKey") .OnDelete(DeleteBehavior.SetNull); - modelBuilder.Entity() + builder.Entity() .HasOne(p => p.OneToOnePerson) .WithOne(p => p.OneToOneTodoItem) .HasForeignKey("OneToOnePersonKey"); - modelBuilder.Entity() + builder.Entity() .HasOne(p => p.Owner) .WithMany(p => p.TodoCollections) .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() + builder.Entity() .HasOne(p => p.Role) .WithOne(p => p.Person) .HasForeignKey("PersonRoleKey"); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs index 1a9017472e..0f64043bd8 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs @@ -13,17 +13,17 @@ public CompositeDbContext(DbContextOptions options) { } - protected override void OnModelCreating(ModelBuilder modelBuilder) + protected override void OnModelCreating(ModelBuilder builder) { - modelBuilder.Entity() + builder.Entity() .HasKey(car => new {car.RegionId, car.LicensePlate}); - modelBuilder.Entity() + builder.Entity() .HasOne(engine => engine.Car) .WithOne(car => car.Engine) .HasForeignKey(); - modelBuilder.Entity() + builder.Entity() .HasMany(dealership => dealership.Inventory) .WithOne(car => car.Dealership); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs index 6236b7fdef..5550add9e2 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs @@ -26,9 +26,9 @@ public async Task Returns_resource_meta_from_ResourceDefinition() // Arrange var todoItems = new[] { - new TodoItem {Id = 1, Description = "Important: Pay the bills"}, - new TodoItem {Id = 2, Description = "Plan my birthday party"}, - new TodoItem {Id = 3, Description = "Important: Call mom"} + new TodoItem {Description = "Important: Pay the bills"}, + new TodoItem {Description = "Plan my birthday party"}, + new TodoItem {Description = "Important: Call mom"} }; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -61,7 +61,7 @@ public async Task Returns_resource_meta_from_ResourceDefinition_in_included_reso { TodoItems = new HashSet { - new TodoItem {Id = 1, Description = "Important: Pay the bills"} + new TodoItem {Description = "Important: Pay the bills"} } }; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/AccountPreferences.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/AccountPreferences.cs new file mode 100644 index 0000000000..ecc7308906 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/AccountPreferences.cs @@ -0,0 +1,11 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings +{ + public sealed class AccountPreferences : Identifiable + { + [Attr] + public bool UseDarkTheme { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Blog.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Blog.cs new file mode 100644 index 0000000000..cc1e1765e9 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Blog.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings +{ + public sealed class Blog : Identifiable + { + [Attr] + public string Title { get; set; } + + [Attr] + public string PlatformName { get; set; } + + [Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))] + public bool ShowAdvertisements => PlatformName.EndsWith("(using free account)"); + + [HasMany] + public IList Posts { get; set; } + + [HasOne] + public WebAccount Owner { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/BlogPost.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/BlogPost.cs new file mode 100644 index 0000000000..10205816ab --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/BlogPost.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings +{ + public sealed class BlogPost : Identifiable + { + [Attr] + public string Caption { get; set; } + + [Attr] + public string Url { get; set; } + + [HasOne] + public WebAccount Author { get; set; } + + [HasOne] + public WebAccount Reviewer { get; set; } + + [NotMapped] + [HasManyThrough(nameof(BlogPostLabels))] + public ISet
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/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index a820acbd3a..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; @@ -70,6 +71,9 @@ public sealed class JsonApiOptions : IJsonApiOptions /// public int? MaximumOperationsPerRequest { get; set; } = 10; + /// + public IsolationLevel? TransactionIsolationLevel { get; } + /// public JsonSerializerSettings SerializerSettings { get; } = new JsonSerializerSettings { 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 @@ + From 2516d2ed4b95b972d9cc7c15afae8bbb08711c48 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 8 Feb 2021 14:45:02 +0100 Subject: [PATCH 120/123] Rename --- .../{AppDbContextExtensions.cs => DbContextExtensions.cs} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename test/TestBuildingBlocks/{AppDbContextExtensions.cs => DbContextExtensions.cs} (98%) diff --git a/test/TestBuildingBlocks/AppDbContextExtensions.cs b/test/TestBuildingBlocks/DbContextExtensions.cs similarity index 98% rename from test/TestBuildingBlocks/AppDbContextExtensions.cs rename to test/TestBuildingBlocks/DbContextExtensions.cs index e518d103c0..b77d46f936 100644 --- a/test/TestBuildingBlocks/AppDbContextExtensions.cs +++ b/test/TestBuildingBlocks/DbContextExtensions.cs @@ -5,7 +5,7 @@ namespace TestBuildingBlocks { - public static class AppDbContextExtensions + public static class DbContextExtensions { public static async Task ClearTableAsync(this DbContext dbContext) where TEntity : class { From 114dc07232dbd0564f26b1f8e7dbd382638b5461 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 8 Feb 2021 15:21:52 +0100 Subject: [PATCH 121/123] Refactored tests for logging --- .../Logging/AuditDbContext.cs | 14 +++ .../Logging/AuditEntriesController.cs | 16 ++++ .../IntegrationTests/Logging/AuditEntry.cs | 15 ++++ .../IntegrationTests/Logging/AuditFakers.cs | 17 ++++ .../IntegrationTests/Logging/LoggingTests.cs | 88 +++++++++++++++---- 5 files changed, 134 insertions(+), 16 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditEntriesController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditEntry.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditFakers.cs diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditDbContext.cs new file mode 100644 index 0000000000..f640452877 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditDbContext.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Logging +{ + public sealed class AuditDbContext : DbContext + { + public DbSet AuditEntries { get; set; } + + public AuditDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditEntriesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditEntriesController.cs new file mode 100644 index 0000000000..5a1e4d74e7 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditEntriesController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Logging +{ + public sealed class AuditEntriesController : JsonApiController + { + public AuditEntriesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditEntry.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditEntry.cs new file mode 100644 index 0000000000..e9fcfcfe5e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditEntry.cs @@ -0,0 +1,15 @@ +using System; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Logging +{ + public sealed class AuditEntry : Identifiable + { + [Attr] + public string UserName { get; set; } + + [Attr] + public DateTimeOffset CreatedAt { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditFakers.cs new file mode 100644 index 0000000000..214fcd9bda --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditFakers.cs @@ -0,0 +1,17 @@ +using System; +using Bogus; +using TestBuildingBlocks; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Logging +{ + internal sealed class AuditFakers : FakerContainer + { + private readonly Lazy> _lazyAuditEntryFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(auditEntry => auditEntry.UserName, f => f.Internet.UserName()) + .RuleFor(auditEntry => auditEntry.CreatedAt, f => f.Date.PastOffset())); + + public Faker AuditEntry => _lazyAuditEntryFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs index 388c77aa58..754aedc690 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs @@ -1,10 +1,7 @@ using System.Net; using System.Threading.Tasks; using FluentAssertions; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using TestBuildingBlocks; @@ -12,11 +9,13 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Logging { - public sealed class LoggingTests : IClassFixture> + public sealed class LoggingTests + : IClassFixture, AuditDbContext>> { - private readonly ExampleIntegrationTestContext _testContext; + private readonly ExampleIntegrationTestContext, AuditDbContext> _testContext; + private readonly AuditFakers _fakers = new AuditFakers(); - public LoggingTests(ExampleIntegrationTestContext testContext) + public LoggingTests(ExampleIntegrationTestContext, AuditDbContext> testContext) { _testContext = testContext; @@ -29,8 +28,7 @@ public LoggingTests(ExampleIntegrationTestContext testCon options.ClearProviders(); options.AddProvider(loggerFactory); options.SetMinimumLevel(LogLevel.Trace); - options.AddFilter((category, level) => level == LogLevel.Trace && - (category == typeof(JsonApiReader).FullName || category == typeof(JsonApiWriter).FullName)); + options.AddFilter((category, level) => true); }); testContext.ConfigureServicesBeforeStartup(services => @@ -43,7 +41,66 @@ public LoggingTests(ExampleIntegrationTestContext testCon } [Fact] - public async Task Logs_request_body_on_error() + public async Task Logs_request_body_at_Trace_level() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); + + var newEntry = _fakers.AuditEntry.Generate(); + + var requestBody = new + { + data = new + { + type = "auditEntries", + attributes = new + { + userName = newEntry.UserName, + createdAt = newEntry.CreatedAt + } + } + }; + + // Arrange + var route = "/auditEntries"; + + // Act + var (httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + loggerFactory.Logger.Messages.Should().NotBeEmpty(); + + loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Trace && + message.Text.StartsWith("Received request at 'http://localhost/auditEntries' with body: <<")); + } + + [Fact] + public async Task Logs_response_body_at_Trace_level() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); + + // Arrange + var route = "/auditEntries"; + + // Act + var (httpResponse, _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + loggerFactory.Logger.Messages.Should().NotBeEmpty(); + + loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Trace && + message.Text.StartsWith("Sending 200 response for request at 'http://localhost/auditEntries' with body: <<")); + } + + [Fact] + public async Task Logs_invalid_request_body_error_at_Information_level() { // Arrange var loggerFactory = _testContext.Factory.Services.GetRequiredService(); @@ -52,19 +109,18 @@ public async Task Logs_request_body_on_error() // Arrange var requestBody = "{ \"data\" {"; - var route = "/api/v1/todoItems"; + var route = "/auditEntries"; // Act - var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + var (httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.Should().HaveCount(1); + loggerFactory.Logger.Messages.Should().NotBeEmpty(); - loggerFactory.Logger.Messages.Should().HaveCount(2); - loggerFactory.Logger.Messages.Should().Contain(message => message.Text.StartsWith("Received request at ") && message.Text.Contains("with body:")); - loggerFactory.Logger.Messages.Should().Contain(message => message.Text.StartsWith("Sending 422 response for request at ") && message.Text.Contains("Failed to deserialize request body.")); + loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Information && + message.Text.Contains("Failed to deserialize request body.")); } } } From 37487cfec208e136191f1908de13c09ea9db70fe Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 8 Feb 2021 15:40:14 +0100 Subject: [PATCH 122/123] Refactored tests for meta --- .../Meta/ProductFamiliesController.cs | 16 ++++++ .../IntegrationTests/Meta/ProductFamily.cs | 15 ++++++ .../Meta/ResourceMetaTests.cs | 52 +++++++++---------- .../Meta/ResponseMetaTests.cs | 24 +++++---- .../IntegrationTests/Meta/SupportDbContext.cs | 15 ++++++ .../IntegrationTests/Meta/SupportFakers.cs | 22 ++++++++ ...ResponseMeta.cs => SupportResponseMeta.cs} | 2 +- .../IntegrationTests/Meta/SupportTicket.cs | 11 ++++ .../Meta/SupportTicketDefinition.cs | 11 ++-- .../Meta/SupportTicketsController.cs | 16 ++++++ .../Meta/TopLevelCountTests.cs | 50 ++++++++++-------- 11 files changed, 167 insertions(+), 67 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ProductFamiliesController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ProductFamily.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportFakers.cs rename test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/{TestResponseMeta.cs => SupportResponseMeta.cs} (91%) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicket.cs rename src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs => test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicketDefinition.cs (55%) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicketsController.cs diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ProductFamiliesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ProductFamiliesController.cs new file mode 100644 index 0000000000..94b827c2f7 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ProductFamiliesController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta +{ + public sealed class ProductFamiliesController : JsonApiController + { + public ProductFamiliesController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ProductFamily.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ProductFamily.cs new file mode 100644 index 0000000000..75a8e34441 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ProductFamily.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta +{ + public sealed class ProductFamily : Identifiable + { + [Attr] + public string Name { get; set; } + + [HasMany] + public IList Tickets { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs index 5550add9e2..6a943c0615 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs @@ -1,45 +1,47 @@ -using System.Collections.Generic; using System.Net; using System.Threading.Tasks; using FluentAssertions; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta { - public sealed class ResourceMetaTests : IClassFixture> + public sealed class ResourceMetaTests + : IClassFixture, SupportDbContext>> { - private readonly ExampleIntegrationTestContext _testContext; + private readonly ExampleIntegrationTestContext, SupportDbContext> _testContext; + private readonly SupportFakers _fakers = new SupportFakers(); - public ResourceMetaTests(ExampleIntegrationTestContext testContext) + public ResourceMetaTests(ExampleIntegrationTestContext, SupportDbContext> testContext) { _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddScoped, SupportTicketDefinition>(); + }); } [Fact] public async Task Returns_resource_meta_from_ResourceDefinition() { // Arrange - var todoItems = new[] - { - new TodoItem {Description = "Important: Pay the bills"}, - new TodoItem {Description = "Plan my birthday party"}, - new TodoItem {Description = "Important: Call mom"} - }; + var tickets = _fakers.SupportTicket.Generate(3); + tickets[0].Description = "Critical: " + tickets[0].Description; + tickets[2].Description = "Critical: " + tickets[2].Description; await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); - dbContext.TodoItems.AddRange(todoItems); - + await dbContext.ClearTableAsync(); + dbContext.SupportTickets.AddRange(tickets); await dbContext.SaveChangesAsync(); }); - var route = "/api/v1/todoItems"; + var route = "/supportTickets"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -57,22 +59,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Returns_resource_meta_from_ResourceDefinition_in_included_resources() { // Arrange - var person = new Person - { - TodoItems = new HashSet - { - new TodoItem {Description = "Important: Pay the bills"} - } - }; + var family = _fakers.ProductFamily.Generate(); + family.Tickets = _fakers.SupportTicket.Generate(1); + family.Tickets[0].Description = "Critical: " + family.Tickets[0].Description; await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); - dbContext.People.Add(person); + await dbContext.ClearTableAsync(); + dbContext.ProductFamilies.Add(family); await dbContext.SaveChangesAsync(); }); - var route = $"/api/v1/people/{person.StringId}?include=todoItems"; + var route = $"/productFamilies/{family.StringId}?include=tickets"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs index dd0464d5d6..71a85c5122 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs @@ -3,26 +3,25 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta { - public sealed class ResponseMetaTests : IClassFixture> + public sealed class ResponseMetaTests + : IClassFixture, SupportDbContext>> { - private readonly ExampleIntegrationTestContext _testContext; + private readonly ExampleIntegrationTestContext, SupportDbContext> _testContext; - public ResponseMetaTests(ExampleIntegrationTestContext testContext) + public ResponseMetaTests(ExampleIntegrationTestContext, SupportDbContext> testContext) { _testContext = testContext; testContext.ConfigureServicesAfterStartup(services => { - services.AddSingleton(); + services.AddSingleton(); }); var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); @@ -33,9 +32,12 @@ public ResponseMetaTests(ExampleIntegrationTestContext te public async Task Returns_top_level_meta() { // Arrange - await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); - var route = "/api/v1/people"; + var route = "/supportTickets"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -55,8 +57,8 @@ public async Task Returns_top_level_meta() ] }, ""links"": { - ""self"": ""http://localhost/api/v1/people"", - ""first"": ""http://localhost/api/v1/people"" + ""self"": ""http://localhost/supportTickets"", + ""first"": ""http://localhost/supportTickets"" }, ""data"": [] }"); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportDbContext.cs new file mode 100644 index 0000000000..6d8850c63a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportDbContext.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta +{ + public sealed class SupportDbContext : DbContext + { + public DbSet ProductFamilies { get; set; } + public DbSet SupportTickets { get; set; } + + public SupportDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportFakers.cs new file mode 100644 index 0000000000..9fd4704cdd --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportFakers.cs @@ -0,0 +1,22 @@ +using System; +using Bogus; +using TestBuildingBlocks; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta +{ + internal sealed class SupportFakers : FakerContainer + { + private readonly Lazy> _lazyProductFamilyFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(productFamily => productFamily.Name, f => f.Commerce.ProductName())); + + private readonly Lazy> _lazySupportTicketFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(supportTicket => supportTicket.Description, f => f.Lorem.Paragraph())); + + public Faker ProductFamily => _lazyProductFamilyFaker.Value; + public Faker SupportTicket => _lazySupportTicketFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TestResponseMeta.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportResponseMeta.cs similarity index 91% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TestResponseMeta.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportResponseMeta.cs index 913986f316..1b62252195 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TestResponseMeta.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportResponseMeta.cs @@ -3,7 +3,7 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta { - public sealed class TestResponseMeta : IResponseMeta + public sealed class SupportResponseMeta : IResponseMeta { public IReadOnlyDictionary GetMeta() { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicket.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicket.cs new file mode 100644 index 0000000000..b006ba51e2 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicket.cs @@ -0,0 +1,11 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta +{ + public sealed class SupportTicket : Identifiable + { + [Attr] + public string Description { get; set; } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicketDefinition.cs similarity index 55% rename from src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicketDefinition.cs index 5d0dc01cf5..3b7b945dae 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicketDefinition.cs @@ -1,19 +1,18 @@ using System.Collections.Generic; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCoreExample.Models; -namespace JsonApiDotNetCoreExample.Definitions +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta { - public sealed class TodoItemDefinition : JsonApiResourceDefinition + public sealed class SupportTicketDefinition : JsonApiResourceDefinition { - public TodoItemDefinition(IResourceGraph resourceGraph) : base(resourceGraph) + public SupportTicketDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } - public override IDictionary GetMeta(TodoItem resource) + public override IDictionary GetMeta(SupportTicket resource) { - if (resource.Description != null && resource.Description.StartsWith("Important:")) + if (resource.Description != null && resource.Description.StartsWith("Critical:")) { return new Dictionary { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicketsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicketsController.cs new file mode 100644 index 0000000000..ad155dc489 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicketsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta +{ + public sealed class SupportTicketsController : JsonApiController + { + public SupportTicketsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs index 4b2823f6cc..efbab3be27 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs @@ -4,20 +4,20 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample; -using JsonApiDotNetCoreExample.Data; -using JsonApiDotNetCoreExample.Models; +using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta { - public sealed class TopLevelCountTests : IClassFixture> + public sealed class TopLevelCountTests + : IClassFixture, SupportDbContext>> { - private readonly ExampleIntegrationTestContext _testContext; + private readonly ExampleIntegrationTestContext, SupportDbContext> _testContext; + private readonly SupportFakers _fakers = new SupportFakers(); - public TopLevelCountTests(ExampleIntegrationTestContext testContext) + public TopLevelCountTests(ExampleIntegrationTestContext, SupportDbContext> testContext) { _testContext = testContext; @@ -34,17 +34,16 @@ public TopLevelCountTests(ExampleIntegrationTestContext t public async Task Renders_resource_count_for_collection() { // Arrange - var todoItem = new TodoItem(); + var ticket = _fakers.SupportTicket.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); - dbContext.TodoItems.Add(todoItem); - + await dbContext.ClearTableAsync(); + dbContext.SupportTickets.Add(ticket); await dbContext.SaveChangesAsync(); }); - var route = "/api/v1/todoItems"; + var route = "/supportTickets"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -60,9 +59,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Renders_resource_count_for_empty_collection() { // Arrange - await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); }); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); - var route = "/api/v1/todoItems"; + var route = "/supportTickets"; // Act var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -78,19 +80,21 @@ public async Task Renders_resource_count_for_empty_collection() public async Task Hides_resource_count_in_create_resource_response() { // Arrange + var newDescription = _fakers.SupportTicket.Generate().Description; + var requestBody = new { data = new { - type = "todoItems", + type = "supportTickets", attributes = new { - description = "Something" + description = newDescription } } }; - var route = "/api/v1/todoItems"; + var route = "/supportTickets"; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -105,11 +109,13 @@ public async Task Hides_resource_count_in_create_resource_response() public async Task Hides_resource_count_in_update_resource_response() { // Arrange - var todoItem = new TodoItem(); + var existingTicket = _fakers.SupportTicket.Generate(); + + var newDescription = _fakers.SupportTicket.Generate().Description; await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.TodoItems.Add(todoItem); + dbContext.SupportTickets.Add(existingTicket); await dbContext.SaveChangesAsync(); }); @@ -117,16 +123,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "todoItems", - id = todoItem.StringId, + type = "supportTickets", + id = existingTicket.StringId, attributes = new { - description = "Something else" + description = newDescription } } }; - var route = "/api/v1/todoItems/" + todoItem.StringId; + var route = "/supportTickets/" + existingTicket.StringId; // Act var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); From d9df27d9c07bf0ec215da66adbbf99588286899c Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Mon, 8 Feb 2021 17:26:12 +0100 Subject: [PATCH 123/123] Post-merge fixes --- .../Middleware/ExceptionHandler.cs | 1 - .../FrozenSystemClock.cs | 20 ------------- ...micConstrainedOperationsControllerTests.cs | 10 ++++--- .../Creating/AtomicCreateResourceTests.cs | 12 ++++---- ...reateResourceWithClientGeneratedIdTests.cs | 10 ++++--- ...eateResourceWithToManyRelationshipTests.cs | 10 ++++--- ...reateResourceWithToOneRelationshipTests.cs | 10 ++++--- .../Deleting/AtomicDeleteResourceTests.cs | 10 ++++--- .../Links/AtomicAbsoluteLinksTests.cs | 10 ++++--- .../AtomicRelativeLinksWithNamespaceTests.cs | 11 ++++---- .../LocalIds/AtomicLocalIdTests.cs | 10 ++++--- .../Meta/AtomicResourceMetaTests.cs | 10 ++++--- .../Meta/AtomicResponseMetaTests.cs | 11 +++++--- .../Mixed/AtomicRequestBodyTests.cs | 10 ++++--- .../Mixed/MaximumOperationsPerRequestTests.cs | 11 ++++---- .../AtomicModelStateValidationTests.cs | 10 ++++--- .../NeverSameResourceChangeTracker.cs | 28 ------------------- .../AtomicOperations/OperationsFakers.cs | 1 + .../QueryStrings/AtomicQueryStringTests.cs | 16 ++++++----- ...icSparseFieldSetResourceDefinitionTests.cs | 10 ++++--- .../Transactions/AtomicRollbackTests.cs | 10 ++++--- .../AtomicTransactionConsistencyTests.cs | 10 ++++--- .../AtomicAddToToManyRelationshipTests.cs | 10 ++++--- ...AtomicRemoveFromToManyRelationshipTests.cs | 10 ++++--- .../AtomicReplaceToManyRelationshipTests.cs | 10 ++++--- .../AtomicUpdateToOneRelationshipTests.cs | 10 ++++--- .../AtomicReplaceToManyRelationshipTests.cs | 10 ++++--- .../Resources/AtomicUpdateResourceTests.cs | 10 ++++--- .../AtomicUpdateToOneRelationshipTests.cs | 10 ++++--- .../ContentNegotiation/AcceptHeaderTests.cs | 2 +- .../ContentTypeHeaderTests.cs | 2 +- .../DisableQueryStringTests.cs | 1 - .../ServiceCollectionExtensions.cs | 15 ---------- .../ModelStateValidationStartup.cs} | 9 +++--- test/TestBuildingBlocks/IntegrationTest.cs | 10 +++++++ .../Models/ResourceConstructionTests.cs | 18 ++++++++---- 36 files changed, 184 insertions(+), 184 deletions(-) delete mode 100644 test/JsonApiDotNetCoreExampleTests/FrozenSystemClock.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/NeverSameResourceChangeTracker.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/ServiceCollectionExtensions.cs rename test/JsonApiDotNetCoreExampleTests/{IntegrationTests/RelativeApiNamespaceStartup.cs => Startups/ModelStateValidationStartup.cs} (60%) diff --git a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs index 25b77b734a..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; diff --git a/test/JsonApiDotNetCoreExampleTests/FrozenSystemClock.cs b/test/JsonApiDotNetCoreExampleTests/FrozenSystemClock.cs deleted file mode 100644 index 2bf19b251d..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/FrozenSystemClock.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using Microsoft.AspNetCore.Authentication; - -namespace JsonApiDotNetCoreExampleTests -{ - internal sealed class FrozenSystemClock : ISystemClock - { - public DateTimeOffset UtcNow { get; } - - public FrozenSystemClock() - : this(new DateTimeOffset(new DateTime(2000, 1, 1))) - { - } - - public FrozenSystemClock(DateTimeOffset utcNow) - { - UtcNow = utcNow; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs index 269c7e75f3..90884f55a3 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs @@ -2,21 +2,23 @@ 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>> + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicConstrainedOperationsControllerTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicConstrainedOperationsControllerTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; - testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index e345d07a89..c073964d81 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -6,22 +6,24 @@ 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>> + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicCreateResourceTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicCreateResourceTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; - testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); } [Fact] @@ -126,7 +128,7 @@ public async Task Can_create_resources() 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"].Should().BeApproximately(newTracks[index].LengthInSeconds); + 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(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs index d792fe548c..4befbd34db 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -4,23 +4,25 @@ 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>> + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicCreateResourceWithClientGeneratedIdTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicCreateResourceWithClientGeneratedIdTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; - testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); var options = (JsonApiOptions) testContext.Factory.Services.GetRequiredService(); options.AllowClientGeneratedIds = true; diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs index c053fb28cd..4c87dfa89f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs @@ -3,22 +3,24 @@ 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>> + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicCreateResourceWithToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicCreateResourceWithToManyRelationshipTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; - testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs index 5e6780f609..d5603ec35a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs @@ -5,23 +5,25 @@ 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>> + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicCreateResourceWithToOneRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicCreateResourceWithToOneRelationshipTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; - testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs index 4cb1d550b5..67716414a5 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs @@ -5,22 +5,24 @@ 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>> + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicDeleteResourceTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicDeleteResourceTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; - testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs index 34678cbd31..21f91eca53 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs @@ -3,24 +3,26 @@ 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>> + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicAbsoluteLinksTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicAbsoluteLinksTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; testContext.ConfigureServicesAfterStartup(services => { - services.AddControllersFromTestProject(); + services.AddControllersFromExampleProject(); services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); }); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs index b8db1e633d..cb3585d95f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs @@ -4,24 +4,25 @@ 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>> + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - public AtomicRelativeLinksWithNamespaceTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicRelativeLinksWithNamespaceTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; testContext.ConfigureServicesAfterStartup(services => { - services.AddControllersFromTestProject(); + services.AddControllersFromExampleProject(); services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); }); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs index 64c03d3290..63cc817103 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs @@ -3,22 +3,24 @@ 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>> + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicLocalIdTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicLocalIdTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; - testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs index 25e1274ba0..b366560d13 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs @@ -5,24 +5,26 @@ 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>> + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicResourceMetaTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicResourceMetaTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; testContext.ConfigureServicesAfterStartup(services => { - services.AddControllersFromTestProject(); + services.AddControllersFromExampleProject(); services.AddScoped, MusicTrackMetaDefinition>(); services.AddScoped, TextLanguageMetaDefinition>(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs index 00f29c02a9..4fa740e292 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs @@ -5,24 +5,27 @@ 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>> + public sealed class AtomicResponseMetaTests + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicResponseMetaTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicResponseMetaTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; testContext.ConfigureServicesAfterStartup(services => { - services.AddControllersFromTestProject(); + services.AddControllersFromExampleProject(); services.AddSingleton(); }); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs index 65399322e1..05d4196a47 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs @@ -2,21 +2,23 @@ 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>> + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - public AtomicRequestBodyTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicRequestBodyTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; - testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs index f86f2bfd11..931af9e668 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs @@ -4,22 +4,23 @@ 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>> + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - public MaximumOperationsPerRequestTests(IntegrationTestContext, OperationsDbContext> testContext) + public MaximumOperationsPerRequestTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; - testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs index 5d8847293e..ef7c2d5a14 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs @@ -2,22 +2,24 @@ 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>> + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicModelStateValidationTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicModelStateValidationTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; - testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/NeverSameResourceChangeTracker.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/NeverSameResourceChangeTracker.cs deleted file mode 100644 index 236dc31923..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/NeverSameResourceChangeTracker.cs +++ /dev/null @@ -1,28 +0,0 @@ -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations -{ - /// - /// Ensures the resource attributes are returned when creating/updating a resource. - /// - internal sealed class NeverSameResourceChangeTracker : IResourceChangeTracker - where TResource : class, IIdentifiable - { - public void SetInitiallyStoredAttributeValues(TResource resource) - { - } - - public void SetRequestedAttributeValues(TResource resource) - { - } - - public void SetFinallyStoredAttributeValues(TResource resource) - { - } - - public bool HasImplicitChanges() - { - return true; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsFakers.cs index e77bdaa1a6..63734c5523 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsFakers.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsFakers.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Linq; using Bogus; +using TestBuildingBlocks; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs index fb4c019cea..f20ec15e37 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs @@ -6,29 +6,31 @@ 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>> + : IClassFixture, OperationsDbContext>> { private static readonly DateTime _frozenTime = 30.July(2018).At(13, 46, 12); - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicQueryStringTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicQueryStringTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; testContext.ConfigureServicesAfterStartup(services => { - services.AddControllersFromTestProject(); + services.AddControllersFromExampleProject(); - services.AddSingleton(new FrozenSystemClock(_frozenTime)); + services.AddSingleton(new FrozenSystemClock {UtcNow = _frozenTime}); services.AddScoped, MusicTrackReleaseDefinition>(); }); @@ -368,7 +370,7 @@ public async Task Can_use_defaults_on_operations_endpoint() 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"].Should().BeApproximately(newTrackLength); + responseDocument.Results[0].SingleData.Attributes["lengthInSeconds"].As().Should().BeApproximately(newTrackLength, 0.00000000001M); } [Fact] @@ -411,7 +413,7 @@ public async Task Can_use_nulls_on_operations_endpoint() 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"].Should().BeApproximately(newTrackLength); + responseDocument.Results[0].SingleData.Attributes["lengthInSeconds"].As().Should().BeApproximately(newTrackLength, 0.00000000001M); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/AtomicSparseFieldSetResourceDefinitionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/AtomicSparseFieldSetResourceDefinitionTests.cs index db6c9f508d..653ff88a31 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/AtomicSparseFieldSetResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/AtomicSparseFieldSetResourceDefinitionTests.cs @@ -3,24 +3,26 @@ 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>> + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicSparseFieldSetResourceDefinitionTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicSparseFieldSetResourceDefinitionTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; testContext.ConfigureServicesAfterStartup(services => { - services.AddControllersFromTestProject(); + services.AddControllersFromExampleProject(); services.AddSingleton(); services.AddScoped, LyricTextDefinition>(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs index cdc46b0ef3..60f6818305 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs @@ -2,22 +2,24 @@ 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>> + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicRollbackTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicRollbackTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; - testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs index c32cc1da1c..6097ca1ccd 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs @@ -4,24 +4,26 @@ 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>> + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - public AtomicTransactionConsistencyTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicTransactionConsistencyTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; testContext.ConfigureServicesAfterStartup(services => { - services.AddControllersFromTestProject(); + services.AddControllersFromExampleProject(); services.AddResourceRepository(); services.AddResourceRepository(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs index d51a94a938..303632937e 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs @@ -4,22 +4,24 @@ 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>> + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicAddToToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicAddToToManyRelationshipTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; - testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs index aae094e043..c7e77fa4e3 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs @@ -4,22 +4,24 @@ 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>> + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicRemoveFromToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicRemoveFromToManyRelationshipTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; - testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs index 4605666aa7..bb87d19f2c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs @@ -4,22 +4,24 @@ 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>> + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicReplaceToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicReplaceToManyRelationshipTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; - testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs index 3cec2ec60a..2cae0afafa 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs @@ -3,22 +3,24 @@ 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>> + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicUpdateToOneRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicUpdateToOneRelationshipTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; - testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs index fb6bbb712b..e8bdb8db3a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs @@ -4,22 +4,24 @@ 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>> + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicReplaceToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicReplaceToManyRelationshipTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; - testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs index 6ad5e30278..3529dd3e66 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs @@ -6,22 +6,24 @@ 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>> + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicUpdateResourceTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicUpdateResourceTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; - testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs index 61bfab41d7..708ad40fbf 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs @@ -3,22 +3,24 @@ 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>> + : IClassFixture, OperationsDbContext>> { - private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new OperationsFakers(); - public AtomicUpdateToOneRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) + public AtomicUpdateToOneRelationshipTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) { _testContext = testContext; - testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromTestProject()); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs index 4f84ac3a47..f1f9cbbb89 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs @@ -19,7 +19,7 @@ public AcceptHeaderTests(ExampleIntegrationTestContext services.AddControllersFromTestProject()); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs index dbdcd32d13..593f832cff 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs @@ -18,7 +18,7 @@ public ContentTypeHeaderTests(ExampleIntegrationTestContext services.AddControllersFromTestProject()); + testContext.ConfigureServicesAfterStartup(services => services.AddControllersFromExampleProject()); } [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/JsonApiDotNetCoreExampleTests/IntegrationTests/ServiceCollectionExtensions.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ServiceCollectionExtensions.cs deleted file mode 100644 index f0b214af83..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCoreExample; -using Microsoft.AspNetCore.Mvc.ApplicationParts; -using Microsoft.Extensions.DependencyInjection; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests -{ - public static class ServiceCollectionExtensions - { - public static void AddControllersFromTestProject(this IServiceCollection services) - { - var part = new AssemblyPart(typeof(EmptyStartup).Assembly); - services.AddMvcCore().ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(part)); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RelativeApiNamespaceStartup.cs b/test/JsonApiDotNetCoreExampleTests/Startups/ModelStateValidationStartup.cs similarity index 60% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/RelativeApiNamespaceStartup.cs rename to test/JsonApiDotNetCoreExampleTests/Startups/ModelStateValidationStartup.cs index 50d987c58f..8816d19f21 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/RelativeApiNamespaceStartup.cs +++ b/test/JsonApiDotNetCoreExampleTests/Startups/ModelStateValidationStartup.cs @@ -2,12 +2,12 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests +namespace JsonApiDotNetCoreExampleTests.Startups { - public sealed class RelativeApiNamespaceStartup : TestableStartup + public sealed class ModelStateValidationStartup : TestableStartup where TDbContext : DbContext { - public RelativeApiNamespaceStartup(IConfiguration configuration) + public ModelStateValidationStartup(IConfiguration configuration) : base(configuration) { } @@ -16,8 +16,7 @@ protected override void SetJsonApiOptions(JsonApiOptions options) { base.SetJsonApiOptions(options); - options.Namespace = "api"; - options.UseRelativeLinks = true; + options.ValidateModelState = true; } } } 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/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 {