diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings index a32ac146aa..d22ebc6887 100644 --- a/JsonApiDotNetCore.sln.DotSettings +++ b/JsonApiDotNetCore.sln.DotSettings @@ -621,9 +621,12 @@ $left$ = $right$; True True True + True True True True True True + True + True diff --git a/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs b/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs index 7c42e26685..3d9cefc600 100644 --- a/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs +++ b/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs @@ -36,8 +36,10 @@ public JsonApiSerializerBenchmarks() var resourceObjectBuilder = new ResourceObjectBuilder(resourceGraph, new ResourceObjectBuilderSettings()); - _jsonApiSerializer = new ResponseSerializer(metaBuilder, linkBuilder, - includeBuilder, fieldsToSerialize, resourceObjectBuilder, options); + IResourceDefinitionAccessor resourceDefinitionAccessor = new Mock().Object; + + _jsonApiSerializer = new ResponseSerializer(metaBuilder, linkBuilder, includeBuilder, fieldsToSerialize, resourceObjectBuilder, + resourceDefinitionAccessor, options); } private static FieldsToSerialize CreateFieldsToSerialize(IResourceGraph resourceGraph) diff --git a/docs/diagrams/resource-definition-create-resource.svg b/docs/diagrams/resource-definition-create-resource.svg new file mode 100644 index 0000000000..0336a5fd72 --- /dev/null +++ b/docs/diagrams/resource-definition-create-resource.svg @@ -0,0 +1,3 @@ + + +
On Writing
On Writing
201 Created
201 Created
On Deserialize
On Deserialize
Insert (assigns ID, runs triggers)
Insert (assigns ID, runs triggers)
POST /resources
POST /resources
On Serialize
On Serialize
JsonApiDotNetCore
JsonApiDotNetCore
Get
Get
On Write
Succeeded
On Write...
1
1
2
2
4
4
3
3
5
5
On Prepare Write
On Prepare Write
6
6
8
8
7
7
9
9
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/docs/diagrams/resource-definition-delete-resource.svg b/docs/diagrams/resource-definition-delete-resource.svg new file mode 100644 index 0000000000..9d09789dae --- /dev/null +++ b/docs/diagrams/resource-definition-delete-resource.svg @@ -0,0 +1,3 @@ + + +
On Writing
On Writing
204 No Content
204 No Content
DELETE /resources/1
DELETE /resources/1
Delete
Delete
JsonApiDotNetCore
JsonApiDotNetCore
1
1
2
2
3
3
4
4
On Write
Succeeded
On Write...
5
5
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/docs/diagrams/resource-definition-set-relationship.svg b/docs/diagrams/resource-definition-set-relationship.svg new file mode 100644 index 0000000000..e8e87053ab --- /dev/null +++ b/docs/diagrams/resource-definition-set-relationship.svg @@ -0,0 +1,3 @@ + + +
On Prepare Write
On Prepare Write
204 No Content
204 No Content
Get for update (including targeted relationship)
Get for update (including targeted relationship)
Update
Update
JsonApiDotNetCore
JsonApiDotNetCore
On Set
ToMany
Relationship
On Set...
On Write
Succeeded
On Write...
On Writing
On Writing
PATCH /resources/1/relationships/name
PATCH /resources/1/relationships/name
1
1
3
3
2
2
4
4
6
6
5
5
7
7
8
8
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/docs/diagrams/resource-definition-update-resource.svg b/docs/diagrams/resource-definition-update-resource.svg new file mode 100644 index 0000000000..5bafe1a876 --- /dev/null +++ b/docs/diagrams/resource-definition-update-resource.svg @@ -0,0 +1,3 @@ + + +
On Prepare Write
On Prepare Write
On Writing
On Writing
200 OK
200 OK
On Deserialize
On Deserialize
PATCH /resources/1?include=...
PATCH /resources/1?include=...
Get for update
Get for update
Update (runs triggers)
Update (runs triggers)
On Serialize
On Serialize
JsonApiDotNetCore
JsonApiDotNetCore
Get with includes
Get with includes
On Write
Succeeded
On Write...
1
1
3
3
4
4
5
5
6
6
8
8
7
7
9
9
2
2
Viewer does not support full SVG 1.1
\ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-key-blue-lines-blue-blue-blue-blue.svg b/docs/diagrams/source/glyphs/doc-key-blue-lines-blue-blue-blue-blue.svg new file mode 100644 index 0000000000..222c833544 --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-key-blue-lines-blue-blue-blue-blue.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-key-blue-lines-orange-orange-blue-blue.svg b/docs/diagrams/source/glyphs/doc-key-blue-lines-orange-orange-blue-blue.svg new file mode 100644 index 0000000000..c4eed0c97f --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-key-blue-lines-orange-orange-blue-blue.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-key-blue-lines-yellow-orange-orange-blue.svg b/docs/diagrams/source/glyphs/doc-key-blue-lines-yellow-orange-orange-blue.svg new file mode 100644 index 0000000000..5194fdbf77 --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-key-blue-lines-yellow-orange-orange-blue.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-key-blue-lines-yellow-orange-yellow-blue.svg b/docs/diagrams/source/glyphs/doc-key-blue-lines-yellow-orange-yellow-blue.svg new file mode 100644 index 0000000000..ac1ef400dd --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-key-blue-lines-yellow-orange-yellow-blue.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-key-green-lines-green-green-green-green.svg b/docs/diagrams/source/glyphs/doc-key-green-lines-green-green-green-green.svg new file mode 100644 index 0000000000..dd2fa2512c --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-key-green-lines-green-green-green-green.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-key-green.svg b/docs/diagrams/source/glyphs/doc-key-green.svg new file mode 100644 index 0000000000..73640a34e1 --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-key-green.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-key-yellow-lines-yellow-X-yellow-X.svg b/docs/diagrams/source/glyphs/doc-key-yellow-lines-yellow-X-yellow-X.svg new file mode 100644 index 0000000000..eb9fee9311 --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-key-yellow-lines-yellow-X-yellow-X.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-key-yellow.svg b/docs/diagrams/source/glyphs/doc-key-yellow.svg new file mode 100644 index 0000000000..49d3ddb9f0 --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-key-yellow.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-nokey-blue-lines-orange-orange-X-X.svg b/docs/diagrams/source/glyphs/doc-nokey-blue-lines-orange-orange-X-X.svg new file mode 100644 index 0000000000..6b4e27765e --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-nokey-blue-lines-orange-orange-X-X.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-nokey-blue-lines-yellow-orange-orange-X.svg b/docs/diagrams/source/glyphs/doc-nokey-blue-lines-yellow-orange-orange-X.svg new file mode 100644 index 0000000000..6e8b794420 --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-nokey-blue-lines-yellow-orange-orange-X.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-nokey-blue-lines-yellow-orange-yellow-X.svg b/docs/diagrams/source/glyphs/doc-nokey-blue-lines-yellow-orange-yellow-X.svg new file mode 100644 index 0000000000..e9062e5ec5 --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-nokey-blue-lines-yellow-orange-yellow-X.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-nokey-blue.svg b/docs/diagrams/source/glyphs/doc-nokey-blue.svg new file mode 100644 index 0000000000..9e3ce76580 --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-nokey-blue.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/doc-nokey-yellow-lines-yellow-X-yellow-X.svg b/docs/diagrams/source/glyphs/doc-nokey-yellow-lines-yellow-X-yellow-X.svg new file mode 100644 index 0000000000..62889a851d --- /dev/null +++ b/docs/diagrams/source/glyphs/doc-nokey-yellow-lines-yellow-X-yellow-X.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/equals-sign.svg b/docs/diagrams/source/glyphs/equals-sign.svg new file mode 100644 index 0000000000..31d635b71a --- /dev/null +++ b/docs/diagrams/source/glyphs/equals-sign.svg @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/docs/diagrams/source/glyphs/event.svg b/docs/diagrams/source/glyphs/event.svg new file mode 100644 index 0000000000..ae3c88ab43 --- /dev/null +++ b/docs/diagrams/source/glyphs/event.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/multidoc-key-green-lines-green-green-green-green.svg b/docs/diagrams/source/glyphs/multidoc-key-green-lines-green-green-green-green.svg new file mode 100644 index 0000000000..7ab3a41679 --- /dev/null +++ b/docs/diagrams/source/glyphs/multidoc-key-green-lines-green-green-green-green.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/multidoc-key-red-lines-red-red-red-red.svg b/docs/diagrams/source/glyphs/multidoc-key-red-lines-red-red-red-red.svg new file mode 100644 index 0000000000..a8c7a7b5e9 --- /dev/null +++ b/docs/diagrams/source/glyphs/multidoc-key-red-lines-red-red-red-red.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/plus-sign.svg b/docs/diagrams/source/glyphs/plus-sign.svg new file mode 100644 index 0000000000..ae709c4883 --- /dev/null +++ b/docs/diagrams/source/glyphs/plus-sign.svg @@ -0,0 +1,12 @@ + + + + + + + diff --git a/docs/diagrams/source/glyphs/plus.svg b/docs/diagrams/source/glyphs/plus.svg new file mode 100644 index 0000000000..bfcce5dda3 --- /dev/null +++ b/docs/diagrams/source/glyphs/plus.svg @@ -0,0 +1,7 @@ + + + + + Svg Vector Icons : http://www.onlinewebfonts.com/icon + + \ No newline at end of file diff --git a/docs/diagrams/source/glyphs/service-bus.svg b/docs/diagrams/source/glyphs/service-bus.svg new file mode 100644 index 0000000000..d67b837f25 --- /dev/null +++ b/docs/diagrams/source/glyphs/service-bus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/diagrams/source/resource-definition-write-callbacks.drawio b/docs/diagrams/source/resource-definition-write-callbacks.drawio new file mode 100644 index 0000000000..391f538389 --- /dev/null +++ b/docs/diagrams/source/resource-definition-write-callbacks.drawio @@ -0,0 +1 @@ +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 \ No newline at end of file diff --git a/docs/docfx.json b/docs/docfx.json index 3a67909683..6acc17ce7a 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -32,12 +32,11 @@ ], "resource": [ { - "files": [ "images/**" ] + "files": [ "diagrams/*.svg" ] } ], "overwrite": [ { - "files": [ "apidoc/**.md" ], "exclude": [ "obj/**", "_site/**" ] } ], diff --git a/docs/usage/extensibility/controllers.md b/docs/usage/extensibility/controllers.md index f46c9c108e..1f9de7a473 100644 --- a/docs/usage/extensibility/controllers.md +++ b/docs/usage/extensibility/controllers.md @@ -54,7 +54,8 @@ public class ArticlesController : BaseJsonApiController
} [HttpGet("{id}")] - public override async Task GetAsync(int id, CancellationToken cancellationToken) + public override async Task GetAsync(int id, + CancellationToken cancellationToken) { return await base.GetAsync(id, cancellationToken); } diff --git a/docs/usage/extensibility/custom-query-formats.md b/docs/usage/extensibility/custom-query-formats.md deleted file mode 100644 index 2653682fe6..0000000000 --- a/docs/usage/extensibility/custom-query-formats.md +++ /dev/null @@ -1,16 +0,0 @@ -# Custom QueryString parameters - -For information on the built-in query string parameters, see the documentation for them. -In order to add parsing of custom query string parameters, you can implement the `IQueryStringParameterReader` interface and inject it. - -```c# -public class YourQueryStringParameterReader : IQueryStringParameterReader -{ - // ... -} -``` - -```c# -services.AddScoped(); -services.AddScoped(sp => sp.GetService()); -``` diff --git a/docs/usage/extensibility/layer-overview.md b/docs/usage/extensibility/layer-overview.md index 6f3b622e07..ac2f7aa435 100644 --- a/docs/usage/extensibility/layer-overview.md +++ b/docs/usage/extensibility/layer-overview.md @@ -1,33 +1,42 @@ # Layer Overview -By default, data retrieval is distributed across three layers: +By default, data access flows through the next three layers: ``` JsonApiController (required) + JsonApiResourceService : IResourceService + EntityFrameworkCoreRepository : IResourceRepository +``` -+-- JsonApiResourceService : IResourceService +Aside from these pluggable endpoint-oriented layers, we provide a resource-oriented extensibility point: - +-- EntityFrameworkCoreRepository : IResourceRepository +``` +JsonApiResourceDefinition : IResourceDefinition ``` -Customization can be done at any of these layers. However, it is recommended that you make your customizations at the service or the repository layer when possible, to keep the controllers free of unnecessary logic. -You can use the following as a general rule of thumb for where to put business logic: +Resource definition callbacks are invoked from the built-in resource service/repository layers, as well as from the serializer. +For example, `IResourceDefinition.OnSerialize` is invoked whenever a resource is sent back to the client, irrespective of the endpoint. +Likewise, `IResourceDefinition.OnSetToOneRelationshipAsync` is called from a patch-resource-with-relationships endpoint, as well as from patch-relationship. -- `Controller`: simple validation logic that should result in the return of specific HTTP status codes, such as model validation -- `IResourceService`: advanced business logic and replacement of data access mechanisms -- `IResourceRepository`: custom logic that builds on the Entity Framework Core APIs +Customization can be done at any of these extensibility points. It is usually sufficient to place your business logic in a resource definition, but depending +on your needs, you may want to replace other parts by deriving from the built-in classes and override virtual methods or call their protected base methods. -## Replacing Services +## Replacing injected services -**Note:** If you are using auto-discovery, resource services and repositories will be automatically registered for you. +**Note:** If you are using auto-discovery, then resource services, repositories and resource definitions will be automatically registered for you. -Replacing services and repositories is done on a per-resource basis and can be done through dependency injection in your Startup.cs file. +Replacing built-in services is done on a per-resource basis and can be done through dependency injection in your Startup.cs file. +For convenience, extension methods are provided to register layers on all their implemented interfaces. ```c# // Startup.cs public void ConfigureServices(IServiceCollection services) { - services.AddScoped(); - services.AddScoped>(); + services.AddResourceService(); + services.AddResourceRepository(); + services.AddResourceDefinition(); + + services.AddScoped(); + services.AddScoped(); } ``` diff --git a/docs/usage/extensibility/middleware.md b/docs/usage/extensibility/middleware.md index 7cbadd1442..9be350250a 100644 --- a/docs/usage/extensibility/middleware.md +++ b/docs/usage/extensibility/middleware.md @@ -1,6 +1,9 @@ # Middleware -It is possible to replace JsonApiDotNetCore middleware components by configuring the IoC container and by configuring `MvcOptions`. +The default middleware validates incoming `Content-Type` and `Accept` HTTP headers. +Based on routing configuration, it fills `IJsonApiRequest`, an injectable object that contains JSON:API-related information about the request being processed. + +It is possible to replace the built-in middleware components by configuring the IoC container and by configuring `MvcOptions`. ## Configuring the IoC container diff --git a/docs/usage/extensibility/query-strings.md b/docs/usage/extensibility/query-strings.md new file mode 100644 index 0000000000..1411717ffd --- /dev/null +++ b/docs/usage/extensibility/query-strings.md @@ -0,0 +1,38 @@ +# Query string parameters + +The parsing of built-in query string parameters is done by types that implement `IQueryStringParameterReader`, which accepts the incoming parameter value. +Those types also implement `IQueryConstraintProvider`, which they use to expose the parse result. + +The parse result consists of an expression in a scope. For example: + + +``` +?filter[articles]=lessThan(price,'1.23') +``` + +has expression `lessThan(price,'1.23')` in scope `articles`. + +For more information on the various built-in query string parameters, see the documentation for them. + +## Custom query string parameters + +When using Entity Framework Core, resource definitions provide a concise syntax to bind a LINQ expression to a query string parameter. +See [here](~/usage/extensibility/resource-definitions.md#custom-query-string-parameters) for details. + +## Custom query string parsing + +In order to add parsing of custom query string parameters, you can implement the `IQueryStringParameterReader` interface and register your reader. + +```c# +public class YourQueryStringParameterReader : IQueryStringParameterReader +{ + // ... +} +``` + +```c# +services.AddScoped(); +services.AddScoped(sp => sp.GetService()); +``` + +Now you can inject your custom reader in resource services, repositories, resource definitions etc. diff --git a/docs/usage/resources/resource-definitions.md b/docs/usage/extensibility/resource-definitions.md similarity index 61% rename from docs/usage/resources/resource-definitions.md rename to docs/usage/extensibility/resource-definitions.md index e355a30d9a..e278c04fa9 100644 --- a/docs/usage/resources/resource-definitions.md +++ b/docs/usage/extensibility/resource-definitions.md @@ -1,11 +1,19 @@ # Resource Definitions -In order to improve the developer experience, we have introduced a type that makes -common modifications to the default API behavior easier. Resource definitions were first introduced in v2.3.4. +_since v2.3.4_ -Resource definitions are resolved from the dependency injection container, so you can inject dependencies in their constructor. +Resource definitions provide a resource-oriented way to handle custom business logic (irrespective of the originating endpoint). -## Customizing query clauses +They are resolved from the dependency injection container, so you can inject dependencies in their constructor. + +**Note:** Prior to the introduction of auto-discovery (in v3), you needed to register the +`ResourceDefinition` on the container yourself: + +```c# +services.AddScoped, ProductResource>(); +``` + +## Customizing queries _since v4.0_ @@ -21,7 +29,7 @@ from Entity Framework Core `IQueryable` execution. There are some cases where you want attributes (or relationships) conditionally excluded from your resource response. For example, you may accept some sensitive data that should only be exposed to administrators after creation. -Note: to exclude attributes unconditionally, use `[Attr(Capabilities = ~AttrCapabilities.AllowView)]`. +Note: to exclude attributes unconditionally, use `[Attr(Capabilities = ~AttrCapabilities.AllowView)]` on a resource class property. ```c# public class UserDefinition : JsonApiResourceDefinition @@ -78,7 +86,7 @@ Content-Type: application/vnd.api+json } ``` -## Default sort order +### Default sort order You can define the default sort order if no `sort` query string parameter is provided. @@ -106,7 +114,7 @@ public class AccountDefinition : JsonApiResourceDefinition } ``` -## Enforce page size +### Enforce page size You may want to enforce pagination on large database tables. @@ -137,9 +145,9 @@ public class AccessLogDefinition : JsonApiResourceDefinition } ``` -## Exclude soft-deleted resources +### Change filters -Soft-deletion sets `IsSoftDeleted` to `true` instead of actually deleting the record, so you may want to always filter them out. +The next example filters out `Account` resources that are suspended. ```c# public class AccountDefinition : JsonApiResourceDefinition @@ -153,23 +161,25 @@ public class AccountDefinition : JsonApiResourceDefinition { var resourceContext = ResourceGraph.GetResourceContext(); - var isSoftDeletedAttribute = + var isSuspendedAttribute = resourceContext.Attributes.Single(account => - account.Property.Name == nameof(Account.IsSoftDeleted)); + account.Property.Name == nameof(Account.IsSuspended)); - var isNotSoftDeleted = new ComparisonExpression(ComparisonOperator.Equals, - new ResourceFieldChainExpression(isSoftDeletedAttribute), + var isNotSuspended = new ComparisonExpression(ComparisonOperator.Equals, + new ResourceFieldChainExpression(isSuspendedAttribute), new LiteralConstantExpression(bool.FalseString)); return existingFilter == null - ? (FilterExpression) isNotSoftDeleted + ? (FilterExpression) isNotSuspended : new LogicalExpression(LogicalOperator.And, - new[] { isNotSoftDeleted, existingFilter }); + new[] { isNotSuspended, existingFilter }); } } ``` -## Block including related resources +### Block including related resources + +In the example below, an error is returned when a user tries to include the manager of an employee. ```c# public class EmployeeDefinition : JsonApiResourceDefinition @@ -196,14 +206,14 @@ public class EmployeeDefinition : JsonApiResourceDefinition } ``` -## Custom query string parameters +### Custom query string parameters _since v3_ You can define additional query string parameters with the LINQ expression that should be used. If the key is present in a query string, the supplied LINQ expression will be added to the database query. -Note this directly influences the Entity Framework Core `IQueryable`. As opposed to using `OnApplyFilter`, this enables the full range of EF Core functionality. +Note this directly influences the Entity Framework Core `IQueryable`. As opposed to using `OnApplyFilter`, this enables the full range of EF Core operators. But it only works on primary resource endpoints (for example: /articles, but not on /blogs/1/articles or /blogs?include=articles). ```c# @@ -238,11 +248,69 @@ public class ItemDefinition : JsonApiResourceDefinition } ``` -## Using Resource Definitions prior to v3 - -Prior to the introduction of auto-discovery, you needed to register the -`ResourceDefinition` on the container yourself: - -```c# -services.AddScoped, ItemResource>(); -``` +## Handling resource changes + +_since v4.2_ + +Without going into too much details, the diagrams below demonstrate a few scenarios where custom code interacts with write operations. +Click on a diagram to open it full-size in a new window. + +### Create resource + + + + + +1. User sends request to create a resource +2. An empty resource instance is created +3. Developer sets default values for attribute 1 and 2 +4. Attribute 1 and 3 from incoming request are copied into default instance +5. Developer overwrites attribute 3 +6. Row is inserted in database +7. Developer sends notification to service bus +8. The new resource is fetched +9. Resource is sent back to the user + +### Update Resource + + + + + +1. User sends request to update resource with ID 1 +2. Existing resource is fetched from database +3. Developer changes attribute 1 and 2 +4. Attribute 1 and 3 from incoming request are copied into fetched instance +5. Developer overwrites attribute 3 +6. Row is updated in database +7. Developer sends notification to service bus +8. The resource is fetched, along with requested includes +9. Resource with includes is sent back to the user + +### Delete Resource + + + + + +1. User sends request to delete resource with ID 1 +2. Developer runs custom validation logic +3. Row is deleted from database +4. Developer sends notification to service bus +5. Success status is sent back to user + +### Set Relationship + + + + + +1. User sends request to assign two resources (green) to relationship 'name' (black) on resource 1 (yellow) +2. Existing resource (blue) with related resources (red) is fetched from database +3. Developer changes attributes (not shown in diagram for brevity) +4. Developer removes one resource from the to-be-assigned set (green) +5. Existing resources in relationship (red) are replaced with resource from previous step (green) +6. Developer overwrites attributes (not shown in diagram for brevity) +7. Resource and relationship are updated in database +8. Developer sends notification to service bus +9. Success status is sent back to user diff --git a/docs/usage/extensibility/services.md b/docs/usage/extensibility/services.md index 7880189eee..7233756062 100644 --- a/docs/usage/extensibility/services.md +++ b/docs/usage/extensibility/services.md @@ -102,14 +102,20 @@ IResourceService +-- ICreateService | POST / | + +-- IUpdateService + | PATCH /{id} + | +-- IDeleteService | DELETE /{id} | - +-- IUpdateService - | PATCH /{id} + +-- IAddToRelationshipService + | POST /{id}/relationships/{relationship} + | + +-- ISetRelationshipService + | PATCH /{id}/relationships/{relationship} | - +-- IUpdateRelationshipService - PATCH /{id}/relationships/{relationship} + +-- IRemoveFromRelationshipService + DELETE /{id}/relationships/{relationship} ``` In order to take advantage of these interfaces you first need to register the service for each implemented interface. diff --git a/docs/usage/reading/filtering.md b/docs/usage/reading/filtering.md index 90b03462d8..c6b65f9867 100644 --- a/docs/usage/reading/filtering.md +++ b/docs/usage/reading/filtering.md @@ -119,8 +119,8 @@ GET /articles?filter[caption]=tech&filter=expr:equals(caption,'cooking')) HTTP/1 There are multiple ways you can add custom filters: -1. Implementing `IResourceDefinition.OnApplyFilter` (see [here](~/usage/resources/resource-definitions.md#exclude-soft-deleted-resources)) and inject `IRequestQueryStringAccessor`, which works at all depths, but filter operations are constrained to what `FilterExpression` provides -2. Implementing `IResourceDefinition.OnRegisterQueryableHandlersForQueryStringParameters` as [described previously](~/usage/resources/resource-definitions.md#custom-query-string-parameters), which enables the full range of `IQueryable` functionality, but only works on primary endpoints +1. Implementing `IResourceDefinition.OnApplyFilter` (see [here](~/usage/extensibility/resource-definitions.md#change-filters)) and inject `IRequestQueryStringAccessor`, which works at all depths, but filter operations are constrained to what `FilterExpression` provides +2. Implementing `IResourceDefinition.OnRegisterQueryableHandlersForQueryStringParameters` as described [here](~/usage/extensibility/resource-definitions.md#custom-query-string-parameters), which enables the full range of `IQueryable` functionality, but only works on primary endpoints 3. Add an implementation of `IQueryConstraintProvider` to supply additional `FilterExpression`s, which are combined with existing filters using AND operator 4. Override `EntityFrameworkCoreRepository.ApplyQueryLayer` to adapt the `IQueryable` expression just before execution 5. Take a deep dive and plug into reader/parser/tokenizer/visitor/builder for adding additional general-purpose filter operators diff --git a/docs/usage/reading/pagination.md b/docs/usage/reading/pagination.md index 7ab92e2dec..77772288b3 100644 --- a/docs/usage/reading/pagination.md +++ b/docs/usage/reading/pagination.md @@ -22,4 +22,4 @@ GET /api/blogs/1/articles?include=revisions&page[size]=10,revisions:5&page[numbe ## Configuring Default Behavior -You can configure the global default behavior as [described previously](~/usage/options.md#pagination). +You can configure the global default behavior as described [here](~/usage/options.md#pagination). diff --git a/docs/usage/reading/sorting.md b/docs/usage/reading/sorting.md index a6fcb4f771..dfadc325fa 100644 --- a/docs/usage/reading/sorting.md +++ b/docs/usage/reading/sorting.md @@ -52,5 +52,5 @@ This sorts the list of blogs by their captions and included revisions by their p ## Default Sort -See the topic on [Resource Definitions](~/usage/resources/resource-definitions.md) +See the topic on [Resource Definitions](~/usage/extensibility/resource-definitions.md) for overriding the default sort behavior. diff --git a/docs/usage/reading/sparse-fieldset-selection.md b/docs/usage/reading/sparse-fieldset-selection.md index b5510be945..7d90bf9d26 100644 --- a/docs/usage/reading/sparse-fieldset-selection.md +++ b/docs/usage/reading/sparse-fieldset-selection.md @@ -41,4 +41,4 @@ When omitted, you'll get the included resources returned, but without full resou ## Overriding -As a developer, you can force to include and/or exclude specific fields as [described previously](~/usage/resources/resource-definitions.md). +As a developer, you can force to include and/or exclude specific fields as described [here](~/usage/extensibility/resource-definitions.md). diff --git a/docs/usage/resources/hooks.md b/docs/usage/resources/hooks.md index 68295fa44a..e34c4922fd 100644 --- a/docs/usage/resources/hooks.md +++ b/docs/usage/resources/hooks.md @@ -1,7 +1,7 @@ # Resource Hooks -This section covers the usage of **Resource Hooks**, which is a feature of`ResourceHooksDefinition`. See the [ResourceDefinition usage guide](resource-definitions.md) for a general explanation on how to set up a `JsonApiResourceDefinition`. For a quick start, jump right to the [Getting started: most minimal example](#getting-started-most-minimal-example) section. +This section covers the usage of **Resource Hooks**, which is a feature of`ResourceHooksDefinition`. See the [ResourceDefinition usage guide](~/usage/extensibility/resource-definitions.md) for a general explanation on how to set up a `JsonApiResourceDefinition`. For a quick start, jump right to the [Getting started: most minimal example](#getting-started-most-minimal-example) section. > Note: Resource Hooks are an experimental feature and are turned off by default. They are subject to change or be replaced in a future version. diff --git a/docs/usage/resources/index.md b/docs/usage/resources/index.md index ad164d82d4..29f510e543 100644 --- a/docs/usage/resources/index.md +++ b/docs/usage/resources/index.md @@ -29,7 +29,7 @@ you can override the virtual property. public class Person : Identifiable { [Key] - [Column("person_id")] + [Column("PersonID")] public override int Id { get; set; } } ``` diff --git a/docs/usage/resources/relationships.md b/docs/usage/resources/relationships.md index ac31adf14a..2d515872e9 100644 --- a/docs/usage/resources/relationships.md +++ b/docs/usage/resources/relationships.md @@ -1,7 +1,10 @@ # Relationships -In order for navigation properties to be identified in the model, -they should be labeled with the appropriate attribute (either `HasOne`, `HasMany` or `HasManyThrough`). +A relationship is a named link between two resource types, including a direction. +They are similar to [navigation properties in Entity Framework Core](https://docs.microsoft.com/en-us/ef/core/modeling/relationships). + +Relationships come in three flavors: to-one, to-many and many-to-many. +The left side of a relationship is where the relationship is declared, the right side is the resource type it points to. ## HasOne @@ -15,6 +18,9 @@ public class TodoItem : Identifiable } ``` +The left side of this relationship is of type `TodoItem` (public name: "todoItems") and the right side is of type `Person` (public name: "persons"). + + ## HasMany This exposes a to-many relationship. @@ -27,11 +33,14 @@ public class Person : Identifiable } ``` +The left side of this relationship is of type `Person` (public name: "persons") and the right side is of type `TodoItem` (public name: "todoItems"). + + ## HasManyThrough -Currently, Entity Framework Core [does not support](https://github.com/aspnet/EntityFrameworkCore/issues/1368) many-to-many relationships without a join entity. +Earlier versions of Entity Framework Core (up to v5) [did not support](https://github.com/aspnet/EntityFrameworkCore/issues/1368) many-to-many relationships without a join entity. For this reason, we have decided to fill this gap by allowing applications to declare a relationship as `HasManyThrough`. -JsonApiDotNetCore will expose this relationship to the client the same way as any other `HasMany` attribute. +JsonApiDotNetCore will expose this relationship to the client the same way as any other `HasMany` relationship. However, under the covers it will use the join type and Entity Framework Core's APIs to get and set the relationship. ```c# @@ -49,6 +58,9 @@ public class Article : Identifiable } ``` +The left side of this relationship is of type `Article` (public name: "articles") and the right side is of type `Tag` (public name: "tags"). + + ## Name There are two ways the exposed relationship name is determined: diff --git a/docs/usage/routing.md b/docs/usage/routing.md index 10a556173b..9d4ebe5853 100644 --- a/docs/usage/routing.md +++ b/docs/usage/routing.md @@ -1,5 +1,11 @@ # Routing +An endpoint URL provides access to a resource or a relationship. Resource endpoints are divided into: +- Primary endpoints, for example: "/articles" and "/articles/1". +- Secondary endpoints, for example: "/articles/1/author" and "/articles/1/comments". + +In the relationship endpoint "/articles/1/relationships/comments", "articles" is the left side of the relationship and "comments" the right side. + ## Namespacing and Versioning URLs You can add a namespace to all URLs by specifying it in ConfigureServices. diff --git a/docs/usage/toc.md b/docs/usage/toc.md index 82ba96a42f..bcb76c2038 100644 --- a/docs/usage/toc.md +++ b/docs/usage/toc.md @@ -1,7 +1,6 @@ # [Resources](resources/index.md) ## [Attributes](resources/attributes.md) ## [Relationships](resources/relationships.md) -## [Resource Definitions](resources/resource-definitions.md) # Reading data ## [Filtering](reading/filtering.md) @@ -24,8 +23,9 @@ # Extensibility ## [Layer Overview](extensibility/layer-overview.md) +## [Resource Definitions](extensibility/resource-definitions.md) ## [Controllers](extensibility/controllers.md) ## [Resource Services](extensibility/services.md) ## [Resource Repositories](extensibility/repositories.md) ## [Middleware](extensibility/middleware.md) -## [Custom Query Formats](extensibility/custom-query-formats.md) +## [Query Strings](extensibility/query-strings.md) diff --git a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs index 3192ddd85f..815b44bd96 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Building; using JsonApiDotNetCore.Serialization.Client.Internal; using JsonApiDotNetCore.Services; @@ -101,6 +102,19 @@ public static IServiceCollection AddResourceRepository(this IServic return services; } + /// + /// Adds IoC container registrations for the various JsonApiDotNetCore resource definition interfaces, such as + /// and . + /// + public static IServiceCollection AddResourceDefinition(this IServiceCollection services) + { + ArgumentGuard.NotNull(services, nameof(services)); + + RegisterForConstructedType(services, typeof(TResourceDefinition), ServiceDiscoveryFacade.ResourceDefinitionInterfaces); + + return services; + } + private static void RegisterForConstructedType(IServiceCollection services, Type implementationType, IEnumerable openGenericInterfaces) { bool seenCompatibleInterface = false; diff --git a/src/JsonApiDotNetCore/Middleware/OperationKind.cs b/src/JsonApiDotNetCore/Middleware/OperationKind.cs index 7cfbe0f892..e3a528f2b0 100644 --- a/src/JsonApiDotNetCore/Middleware/OperationKind.cs +++ b/src/JsonApiDotNetCore/Middleware/OperationKind.cs @@ -1,15 +1,39 @@ namespace JsonApiDotNetCore.Middleware { /// - /// Lists the functional operation kinds from an atomic:operations request. + /// Lists the functional operation kinds of a resource request or an atomic:operations request. /// public enum OperationKind { + /// + /// Create a new resource with attributes, relationships or both. + /// CreateResource, + + /// + /// 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. + /// UpdateResource, + + /// + /// Delete an existing resource. + /// DeleteResource, + + /// + /// Perform a complete replacement of a relationship on an existing resource. + /// SetRelationship, + + /// + /// Add resources to a to-many relationship. + /// AddToRelationship, + + /// + /// Remove resources from a to-many relationship. + /// RemoveFromRelationship } } diff --git a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs index c3fa8428e4..35094f0e43 100644 --- a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs @@ -1,8 +1,10 @@ +using System; using System.Collections.Generic; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Services; namespace JsonApiDotNetCore.Queries { @@ -57,5 +59,12 @@ QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, Resourc /// Builds a query for a to-many relationship with a filter to match on its left and right resource IDs. /// QueryLayer ComposeForHasMany(HasManyAttribute hasManyRelationship, TId leftId, ICollection rightResourceIds); + + /// + /// Provides access to the request-scoped instance. This method has been added solely to prevent introducing a + /// breaking change in the constructor and will be removed in the next major version. + /// + [Obsolete] + IResourceDefinitionAccessor GetResourceDefinitionAccessor(); } } diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs index ffaab66790..58fc650321 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -414,6 +414,12 @@ public QueryLayer ComposeForHasMany(HasManyAttribute hasManyRelationship, T }; } + /// + public IResourceDefinitionAccessor GetResourceDefinitionAccessor() + { + return _resourceDefinitionAccessor; + } + protected virtual IReadOnlyCollection GetIncludeElements(IReadOnlyCollection includeElements, ResourceContext resourceContext) { diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 36afb6dd7c..24f835c6df 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -35,6 +35,7 @@ public class EntityFrameworkCoreRepository : IResourceRepository private readonly IResourceFactory _resourceFactory; private readonly IEnumerable _constraintProviders; private readonly TraceLogWriter> _traceWriter; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; /// public virtual string TransactionId => _dbContext.Database.CurrentTransaction?.TransactionId.ToString(); @@ -55,6 +56,10 @@ public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IDbContextR _constraintProviders = constraintProviders; _dbContext = contextResolver.GetContext(); _traceWriter = new TraceLogWriter>(loggerFactory); + +#pragma warning disable 612 // Method is obsolete + _resourceDefinitionAccessor = resourceFactory.GetResourceDefinitionAccessor(); +#pragma warning restore 612 } /// @@ -167,7 +172,11 @@ public virtual async Task CreateAsync(TResource resourceFromRequest, TResource r foreach (RelationshipAttribute relationship in _targetedFields.Relationships) { object rightResources = relationship.GetValue(resourceFromRequest); - await UpdateRelationshipAsync(relationship, resourceForDatabase, rightResources, collector, cancellationToken); + + object rightResourcesEdited = await VisitSetRelationshipAsync(resourceForDatabase, relationship, rightResources, OperationKind.CreateResource, + cancellationToken); + + await UpdateRelationshipAsync(relationship, resourceForDatabase, rightResourcesEdited, collector, cancellationToken); } foreach (AttrAttribute attribute in _targetedFields.Attributes) @@ -175,10 +184,36 @@ public virtual async Task CreateAsync(TResource resourceFromRequest, TResource r attribute.SetValue(resourceForDatabase, attribute.GetValue(resourceFromRequest)); } + await _resourceDefinitionAccessor.OnWritingAsync(resourceForDatabase, OperationKind.CreateResource, cancellationToken); + DbSet dbSet = _dbContext.Set(); await dbSet.AddAsync(resourceForDatabase, cancellationToken); await SaveChangesAsync(cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceForDatabase, OperationKind.CreateResource, cancellationToken); + } + + private async Task VisitSetRelationshipAsync(TResource leftResource, RelationshipAttribute relationship, object rightResourceIds, + OperationKind operationKind, CancellationToken cancellationToken) + { + if (relationship is HasOneAttribute hasOneRelationship) + { + return await _resourceDefinitionAccessor.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, (IIdentifiable)rightResourceIds, + operationKind, cancellationToken); + } + + if (relationship is HasManyAttribute hasManyRelationship) + { + HashSet rightResourceIdSet = _collectionConverter.ExtractResources(rightResourceIds).ToHashSet(IdentifiableComparer.Instance); + + await _resourceDefinitionAccessor.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIdSet, operationKind, + cancellationToken); + + return rightResourceIdSet; + } + + return rightResourceIds; } /// @@ -206,9 +241,12 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r { object rightResources = relationship.GetValue(resourceFromRequest); - AssertIsNotClearingRequiredRelationship(relationship, resourceFromDatabase, rightResources); + object rightResourcesEdited = await VisitSetRelationshipAsync(resourceFromDatabase, relationship, rightResources, OperationKind.UpdateResource, + cancellationToken); + + AssertIsNotClearingRequiredRelationship(relationship, resourceFromDatabase, rightResourcesEdited); - await UpdateRelationshipAsync(relationship, resourceFromDatabase, rightResources, collector, cancellationToken); + await UpdateRelationshipAsync(relationship, resourceFromDatabase, rightResourcesEdited, collector, cancellationToken); } foreach (AttrAttribute attribute in _targetedFields.Attributes) @@ -216,7 +254,11 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r attribute.SetValue(resourceFromDatabase, attribute.GetValue(resourceFromRequest)); } + await _resourceDefinitionAccessor.OnWritingAsync(resourceFromDatabase, OperationKind.UpdateResource, cancellationToken); + await SaveChangesAsync(cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceFromDatabase, OperationKind.UpdateResource, cancellationToken); } protected void AssertIsNotClearingRequiredRelationship(RelationshipAttribute relationship, TResource leftResource, object rightValue) @@ -229,9 +271,9 @@ protected void AssertIsNotClearingRequiredRelationship(RelationshipAttribute rel relationshipIsRequired = navigation?.ForeignKey?.IsRequired ?? false; } - bool relationshipIsBeingCleared = relationship is HasOneAttribute - ? rightValue == null - : IsToManyRelationshipBeingCleared(relationship, leftResource, rightValue); + bool relationshipIsBeingCleared = relationship is HasManyAttribute hasManyRelationship + ? IsToManyRelationshipBeingCleared(hasManyRelationship, leftResource, rightValue) + : rightValue == null; if (relationshipIsRequired && relationshipIsBeingCleared) { @@ -240,11 +282,11 @@ protected void AssertIsNotClearingRequiredRelationship(RelationshipAttribute rel } } - private bool IsToManyRelationshipBeingCleared(RelationshipAttribute relationship, TResource leftResource, object valueToAssign) + private bool IsToManyRelationshipBeingCleared(HasManyAttribute hasManyRelationship, TResource leftResource, object valueToAssign) { ICollection newRightResourceIds = _collectionConverter.ExtractResources(valueToAssign); - object existingRightValue = relationship.GetValue(leftResource); + object existingRightValue = hasManyRelationship.GetValue(leftResource); HashSet existingRightResourceIds = _collectionConverter.ExtractResources(existingRightValue).ToHashSet(IdentifiableComparer.Instance); @@ -262,6 +304,13 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke id }); + // This enables OnWritingAsync() to fetch the resource, which adds it to the change tracker. + // If so, we'll reuse the tracked resource instead of a placeholder resource. + var emptyResource = _resourceFactory.CreateInstance(); + emptyResource.Id = id; + + await _resourceDefinitionAccessor.OnWritingAsync(emptyResource, OperationKind.DeleteResource, cancellationToken); + using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext); TResource resource = collector.CreateForId(id); @@ -279,6 +328,8 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke _dbContext.Remove(resource); await SaveChangesAsync(cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(resource, OperationKind.DeleteResource, cancellationToken); } private NavigationEntry GetNavigationEntry(TResource resource, RelationshipAttribute relationship) @@ -340,12 +391,19 @@ public virtual async Task SetRelationshipAsync(TResource primaryResource, object RelationshipAttribute relationship = _targetedFields.Relationships.Single(); - AssertIsNotClearingRequiredRelationship(relationship, primaryResource, secondaryResourceIds); + object secondaryResourceIdsEdited = + await VisitSetRelationshipAsync(primaryResource, relationship, secondaryResourceIds, OperationKind.SetRelationship, cancellationToken); + + AssertIsNotClearingRequiredRelationship(relationship, primaryResource, secondaryResourceIdsEdited); using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext); - await UpdateRelationshipAsync(relationship, primaryResource, secondaryResourceIds, collector, cancellationToken); + await UpdateRelationshipAsync(relationship, primaryResource, secondaryResourceIdsEdited, collector, cancellationToken); + + await _resourceDefinitionAccessor.OnWritingAsync(primaryResource, OperationKind.SetRelationship, cancellationToken); await SaveChangesAsync(cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(primaryResource, OperationKind.SetRelationship, cancellationToken); } /// @@ -359,7 +417,9 @@ public virtual async Task AddToToManyRelationshipAsync(TId primaryId, ISet(primaryId, relationship, secondaryResourceIds, cancellationToken); if (secondaryResourceIds.Any()) { @@ -368,7 +428,11 @@ public virtual async Task AddToToManyRelationshipAsync(TId primaryId, ISet rightResourceIds = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); - rightResourceIds.ExceptWith(secondaryResourceIds); + if (secondaryResourceIds.Any()) + { + object rightValue = relationship.GetValue(primaryResource); - AssertIsNotClearingRequiredRelationship(relationship, primaryResource, rightResourceIds); + HashSet rightResourceIds = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); + rightResourceIds.ExceptWith(secondaryResourceIds); - using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext); - await UpdateRelationshipAsync(relationship, primaryResource, rightResourceIds, collector, cancellationToken); + AssertIsNotClearingRequiredRelationship(relationship, primaryResource, rightResourceIds); - await SaveChangesAsync(cancellationToken); + using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext); + await UpdateRelationshipAsync(relationship, primaryResource, rightResourceIds, collector, cancellationToken); + + await _resourceDefinitionAccessor.OnWritingAsync(primaryResource, OperationKind.RemoveFromRelationship, cancellationToken); + + await SaveChangesAsync(cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(primaryResource, OperationKind.RemoveFromRelationship, cancellationToken); + } } protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship, TResource leftResource, object valueToAssign, @@ -449,6 +522,8 @@ private bool IsOneToOneRelationship(RelationshipAttribute relationship) protected virtual async Task SaveChangesAsync(CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); + try { await _dbContext.SaveChangesAsync(cancellationToken); diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs index 69cf85639d..8664d98914 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs @@ -1,13 +1,16 @@ using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using JetBrains.Annotations; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Resources { /// - /// Provides a resource-centric extensibility point for executing custom code when something happens with a resource. The goal here is to reduce the need - /// for overriding the service and repository layers. + /// Provides an extensibility point to add business logic that is resource-oriented instead of endpoint-oriented. /// /// /// The resource type. @@ -19,8 +22,7 @@ public interface IResourceDefinition : IResourceDefinition - /// Provides a resource-centric extensibility point for executing custom code when something happens with a resource. The goal here is to reduce the need - /// for overriding the service and repository layers. + /// Provides an extensibility point to add business logic that is resource-oriented instead of endpoint-oriented. /// /// /// The resource type. @@ -29,6 +31,7 @@ public interface IResourceDefinition : IResourceDefinition [PublicAPI] + // ReSharper disable once TypeParameterCanBeVariant -- Justification: making TId contravariant is a breaking change. public interface IResourceDefinition where TResource : class, IIdentifiable { @@ -129,5 +132,210 @@ public interface IResourceDefinition /// Enables to add JSON:API meta information, specific to this resource. /// IDictionary GetMeta(TResource resource); + + /// + /// Executes after the original version of the resource has been retrieved from the underlying data store, as part of a write request. + /// + /// Implementing this method enables to perform validations and make changes to , before the fields from the request are + /// copied into it. + /// + /// + /// For POST resource requests, this method is typically used to assign property default values or to set required relationships by side-loading the + /// related resources and linking them. + /// + /// + /// + /// The original resource retrieved from the underlying data store, or a freshly instantiated resource in case of a POST resource request. + /// + /// + /// Identifies from which endpoint this method was called. Possible values: , + /// , and . + /// Note this intentionally excludes and , because for those + /// endpoints no resource is retrieved upfront. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnPrepareWriteAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken); + + /// + /// Executes before setting (or clearing) the resource at the right side of a to-one relationship. + /// + /// Implementing this method enables to perform validations and change , before the relationship is updated. + /// + /// + /// + /// The original resource as retrieved from the underlying data store. The indication "left" specifies that is + /// declared on . + /// + /// + /// The to-one relationship being set. + /// + /// + /// The new resource identifier (or null to clear the relationship), coming from the request. + /// + /// + /// Identifies from which endpoint this method was called. Possible values: , + /// and . + /// + /// + /// Propagates notification that request handling should be canceled. + /// + /// + /// The replacement resource identifier, or null to clear the relationship. Returns by default. + /// + Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, + OperationKind operationKind, CancellationToken cancellationToken); + + /// + /// Executes before setting the resources at the right side of a to-many relationship. This replaces on existing set. + /// + /// Implementing this method enables to perform validations and make changes to , before the relationship is updated. + /// + /// + /// + /// The original resource as retrieved from the underlying data store. The indication "left" specifies that is + /// declared on . + /// + /// + /// The to-many relationship being set. + /// + /// + /// The set of resource identifiers to replace any existing set with, coming from the request. + /// + /// + /// Identifies from which endpoint this method was called. Possible values: , + /// and . + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + OperationKind operationKind, CancellationToken cancellationToken); + + /// + /// Executes before adding resources to the right side of a to-many relationship, as part of a POST relationship request. + /// + /// Implementing this method enables to perform validations and make changes to , before the relationship is updated. + /// + /// + /// + /// Identifier of the left resource. The indication "left" specifies that is declared on + /// . + /// + /// + /// The to-many relationship being added to. + /// + /// + /// The set of resource identifiers to add to the to-many relationship, coming from the request. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken); + + /// + /// Executes before removing resources from the right side of a to-many relationship, as part of a DELETE relationship request. + /// + /// Implementing this method enables to perform validations and make changes to , before the relationship is updated. + /// + /// + /// + /// The original resource as retrieved from the underlying data store. The indication "left" specifies that is + /// declared on . + /// + /// + /// The to-many relationship being removed from. + /// + /// + /// The set of resource identifiers to remove from the to-many relationship, coming from the request. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken); + + /// + /// Executes before writing the changed resource to the underlying data store, as part of a write request. + /// + /// Implementing this method enables to perform validations and make changes to , after the fields from the request have been + /// copied into it. + /// + /// + /// An example usage is to set the last-modification timestamp, overwriting the value from the incoming request. + /// + /// + /// Another use case is to add a notification message to an outbox table, which gets committed along with the resource write in a single transaction (see + /// https://microservices.io/patterns/data/transactional-outbox.html). + /// + /// + /// + /// The original resource retrieved from the underlying data store (or a freshly instantiated resource in case of a POST resource request), updated with + /// the changes from the incoming request. Exception: In case is or + /// , this is an empty object with only the property set, because for + /// those endpoints no resource is retrieved upfront. + /// + /// + /// Identifies from which endpoint this method was called. Possible values: , + /// , , , + /// and . + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnWritingAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken); + + /// + /// Executes after successfully writing the changed resource to the underlying data store, as part of a write request. + /// + /// Implementing this method enables to run additional logic, for example enqueue a notification message on a service bus. + /// + /// + /// + /// The resource as written to the underlying data store. + /// + /// + /// Identifies from which endpoint this method was called. Possible values: , + /// , , , + /// and . + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnWriteSucceededAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken); + + /// + /// Executes after a resource has been deserialized from an incoming request body. + /// + /// + /// Implementing this method enables to change the incoming resource before it enters an ASP.NET Controller Action method. + /// + /// + /// Changing attributes on from this method may break detection of side effects on resource POST/PATCH requests, because + /// side effect detection considers any changes done from this method to be part of the incoming request body. So setting additional attributes from this + /// method (that were not sent by the client) are not considered side effects, resulting in incorrectly reporting that there were no side effects. + /// + /// + /// The deserialized resource. + /// + void OnDeserialize(TResource resource); + + /// + /// Executes before a (primary or included) resource is serialized into an outgoing response body. + /// + /// + /// Implementing this method enables to change the returned resource, for example scrub sensitive data or transform returned attribute values. + /// + /// + /// Changing attributes on from this method may break detection of side effects on resource POST/PATCH requests. What this + /// means is that if side effects were detected before, this is not re-evaluated after running this method, so it may incorrectly report side effects if + /// they were undone by this method. + /// + /// + /// The serialized resource. + /// + void OnSerialize(TResource resource); } } diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs index 90910c8d5b..042a5553c4 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs @@ -1,7 +1,11 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Resources { @@ -45,5 +49,61 @@ public interface IResourceDefinitionAccessor /// Invokes for the specified resource. /// IDictionary GetMeta(Type resourceType, IIdentifiable resourceInstance); + + /// + /// Invokes for the specified resource. + /// + Task OnPrepareWriteAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + public Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable rightResourceId, OperationKind operationKind, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + public Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + OperationKind operationKind, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + public Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + public Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + Task OnWritingAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + Task OnWriteSucceededAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + void OnDeserialize(IIdentifiable resource); + + /// + /// Invokes for the specified resource. + /// + void OnSerialize(IIdentifiable resource); } } diff --git a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs index 38a25ad996..cd520b4f6f 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs @@ -1,5 +1,6 @@ using System; using System.Linq.Expressions; +using JsonApiDotNetCore.Repositories; namespace JsonApiDotNetCore.Resources { @@ -23,5 +24,12 @@ public TResource CreateInstance() /// Returns an expression tree that represents creating a new resource object instance. /// public NewExpression CreateNewExpression(Type resourceType); + + /// + /// Provides access to the request-scoped instance. This method has been added solely to prevent introducing a + /// breaking change in the constructor and will be removed in the next major version. + /// + [Obsolete] + IResourceDefinitionAccessor GetResourceDefinitionAccessor(); } } diff --git a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs index 06987f123a..74aac437be 100644 --- a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs @@ -3,8 +3,11 @@ using System.ComponentModel; using System.Linq; using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; @@ -113,6 +116,62 @@ public virtual IDictionary GetMeta(TResource resource) return null; } + /// + public virtual Task OnPrepareWriteAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public virtual Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable rightResourceId, OperationKind operationKind, CancellationToken cancellationToken) + { + return Task.FromResult(rightResourceId); + } + + /// + public virtual Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + OperationKind operationKind, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public virtual Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public virtual Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public virtual Task OnWritingAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public virtual Task OnWriteSucceededAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public virtual void OnDeserialize(TResource resource) + { + } + + /// + public virtual void OnSerialize(TResource resource) + { + } + /// /// This is an alias type intended to simplify the implementation's method signature. See for usage /// details. diff --git a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs index 9553d7fdfb..b6ed7774ca 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs @@ -1,8 +1,12 @@ using System; using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.DependencyInjection; namespace JsonApiDotNetCore.Resources @@ -89,6 +93,104 @@ public IDictionary GetMeta(Type resourceType, IIdentifiable reso return resourceDefinition.GetMeta((dynamic)resourceInstance); } + /// + public async Task OnPrepareWriteAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentGuard.NotNull(resource, nameof(resource)); + + dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); + await resourceDefinition.OnPrepareWriteAsync(resource, operationKind, cancellationToken); + } + + /// + public async Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable rightResourceId, OperationKind operationKind, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentGuard.NotNull(leftResource, nameof(leftResource)); + ArgumentGuard.NotNull(hasOneRelationship, nameof(hasOneRelationship)); + + dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); + return await resourceDefinition.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, rightResourceId, operationKind, cancellationToken); + } + + /// + public async Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, + ISet rightResourceIds, OperationKind operationKind, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentGuard.NotNull(leftResource, nameof(leftResource)); + ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); + ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + + dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); + await resourceDefinition.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, operationKind, cancellationToken); + } + + /// + public async Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, + ISet rightResourceIds, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); + ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + + dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); + await resourceDefinition.OnAddToRelationshipAsync(leftResourceId, hasManyRelationship, rightResourceIds, cancellationToken); + } + + /// + public async Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, + ISet rightResourceIds, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentGuard.NotNull(leftResource, nameof(leftResource)); + ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); + ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + + dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); + await resourceDefinition.OnRemoveFromRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, cancellationToken); + } + + /// + public async Task OnWritingAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentGuard.NotNull(resource, nameof(resource)); + + dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); + await resourceDefinition.OnWritingAsync(resource, operationKind, cancellationToken); + } + + /// + public async Task OnWriteSucceededAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentGuard.NotNull(resource, nameof(resource)); + + dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); + await resourceDefinition.OnWriteSucceededAsync(resource, operationKind, cancellationToken); + } + + /// + public void OnDeserialize(IIdentifiable resource) + { + ArgumentGuard.NotNull(resource, nameof(resource)); + + dynamic resourceDefinition = ResolveResourceDefinition(resource.GetType()); + resourceDefinition.OnDeserialize((dynamic)resource); + } + + /// + public void OnSerialize(IIdentifiable resource) + { + ArgumentGuard.NotNull(resource, nameof(resource)); + + dynamic resourceDefinition = ResolveResourceDefinition(resource.GetType()); + resourceDefinition.OnSerialize((dynamic)resource); + } + protected virtual object ResolveResourceDefinition(Type resourceType) { ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(resourceType); diff --git a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs index 4b98b8e985..721022d6f5 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs @@ -19,6 +19,12 @@ public ResourceFactory(IServiceProvider serviceProvider) _serviceProvider = serviceProvider; } + /// + public IResourceDefinitionAccessor GetResourceDefinitionAccessor() + { + return _serviceProvider.GetRequiredService(); + } + /// public IIdentifiable CreateInstance(Type resourceType) { diff --git a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs index aeb773dd88..19b95d52b4 100644 --- a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs @@ -20,6 +20,7 @@ public sealed class AtomicOperationsResponseSerializer : BaseSerializer, IJsonAp private readonly IMetaBuilder _metaBuilder; private readonly ILinkBuilder _linkBuilder; private readonly IFieldsToSerialize _fieldsToSerialize; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; private readonly IJsonApiRequest _request; private readonly IJsonApiOptions _options; @@ -27,18 +28,20 @@ public sealed class AtomicOperationsResponseSerializer : BaseSerializer, IJsonAp public string ContentType { get; } = HeaderConstants.AtomicOperationsMediaType; public AtomicOperationsResponseSerializer(IResourceObjectBuilder resourceObjectBuilder, IMetaBuilder metaBuilder, ILinkBuilder linkBuilder, - IFieldsToSerialize fieldsToSerialize, IJsonApiRequest request, IJsonApiOptions options) + IFieldsToSerialize fieldsToSerialize, IResourceDefinitionAccessor resourceDefinitionAccessor, IJsonApiRequest request, IJsonApiOptions options) : base(resourceObjectBuilder) { ArgumentGuard.NotNull(metaBuilder, nameof(metaBuilder)); ArgumentGuard.NotNull(linkBuilder, nameof(linkBuilder)); ArgumentGuard.NotNull(fieldsToSerialize, nameof(fieldsToSerialize)); + ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); ArgumentGuard.NotNull(request, nameof(request)); ArgumentGuard.NotNull(options, nameof(options)); _metaBuilder = metaBuilder; _linkBuilder = linkBuilder; _fieldsToSerialize = fieldsToSerialize; + _resourceDefinitionAccessor = resourceDefinitionAccessor; _request = request; _options = options; } @@ -79,6 +82,8 @@ private AtomicResultObject SerializeOperation(OperationContainer operation) _request.CopyFrom(operation.Request); _fieldsToSerialize.ResetCache(); + _resourceDefinitionAccessor.OnSerialize(operation.Resource); + Type resourceType = operation.Resource.GetType(); IReadOnlyCollection attributes = _fieldsToSerialize.GetAttributes(resourceType); IReadOnlyCollection relationships = _fieldsToSerialize.GetRelationships(resourceType); diff --git a/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs index 7db926c6da..b0bb81f44b 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs @@ -135,8 +135,14 @@ private void ProcessChain(object related, IList inclusion private void ProcessRelationship(IIdentifiable parent, IList inclusionChain) { - // get the resource object for parent. - ResourceObject resourceObject = GetOrBuildResourceObject(parent); + ResourceObject resourceObject = TryGetBuiltResourceObjectFor(parent); + + if (resourceObject == null) + { + _resourceDefinitionAccessor.OnSerialize(parent); + + resourceObject = BuildCachedResourceObjectFor(parent); + } if (!inclusionChain.Any()) { @@ -188,23 +194,25 @@ protected override RelationshipEntry GetRelationshipData(RelationshipAttribute r }; } - /// - /// Gets the resource object for by searching the included list. If it was not already built, it is constructed and added to - /// the inclusion list. - /// - private ResourceObject GetOrBuildResourceObject(IIdentifiable parent) + private ResourceObject TryGetBuiltResourceObjectFor(IIdentifiable resource) { - Type type = parent.GetType(); - string resourceName = ResourceContextProvider.GetResourceContext(type).PublicName; - ResourceObject entry = _included.SingleOrDefault(ro => ro.Type == resourceName && ro.Id == parent.StringId); + Type resourceType = resource.GetType(); + ResourceContext resourceContext = ResourceContextProvider.GetResourceContext(resourceType); - if (entry == null) - { - entry = Build(parent, _fieldsToSerialize.GetAttributes(type), _fieldsToSerialize.GetRelationships(type)); - _included.Add(entry); - } + return _included.SingleOrDefault(resourceObject => resourceObject.Type == resourceContext.PublicName && resourceObject.Id == resource.StringId); + } + + private ResourceObject BuildCachedResourceObjectFor(IIdentifiable resource) + { + Type resourceType = resource.GetType(); + IReadOnlyCollection attributes = _fieldsToSerialize.GetAttributes(resourceType); + IReadOnlyCollection relationships = _fieldsToSerialize.GetRelationships(resourceType); - return entry; + ResourceObject resourceObject = Build(resource, attributes, relationships); + + _included.Add(resourceObject); + + return resourceObject; } } } diff --git a/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs index 763c41020a..763ee59b6c 100644 --- a/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs +++ b/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs @@ -19,6 +19,9 @@ public class FieldsToSerialize : IFieldsToSerialize private readonly IJsonApiRequest _request; private readonly SparseFieldSetCache _sparseFieldSetCache; + /// + public bool ShouldSerialize => _request.Kind != EndpointKind.Relationship; + public FieldsToSerialize(IResourceContextProvider resourceContextProvider, IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor, IJsonApiRequest request) { @@ -35,7 +38,7 @@ public IReadOnlyCollection GetAttributes(Type resourceType) { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - if (_request.Kind == EndpointKind.Relationship) + if (!ShouldSerialize) { return Array.Empty(); } @@ -56,7 +59,7 @@ public IReadOnlyCollection GetRelationships(Type resource { ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - if (_request.Kind == EndpointKind.Relationship) + if (!ShouldSerialize) { return Array.Empty(); } diff --git a/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs index 7f2775c021..682301b040 100644 --- a/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs +++ b/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs @@ -10,6 +10,11 @@ namespace JsonApiDotNetCore.Serialization /// public interface IFieldsToSerialize { + /// + /// Indicates whether attributes and relationships should be serialized, based on the current endpoint. + /// + bool ShouldSerialize { get; } + /// /// Gets the collection of attributes that are to be serialized for resources of type . /// diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index 52e8db1fb0..ba4144bb9d 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -26,6 +26,7 @@ public class RequestDeserializer : BaseDeserializer, IJsonApiDeserializer private readonly IHttpContextAccessor _httpContextAccessor; private readonly IJsonApiRequest _request; private readonly IJsonApiOptions _options; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; public RequestDeserializer(IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory, ITargetedFields targetedFields, IHttpContextAccessor httpContextAccessor, IJsonApiRequest request, IJsonApiOptions options) @@ -40,6 +41,10 @@ public RequestDeserializer(IResourceContextProvider resourceContextProvider, IRe _httpContextAccessor = httpContextAccessor; _request = request; _options = options; + +#pragma warning disable 612 // Method is obsolete + _resourceDefinitionAccessor = resourceFactory.GetResourceDefinitionAccessor(); +#pragma warning restore 612 } /// @@ -59,6 +64,11 @@ public object Deserialize(string body) object instance = DeserializeBody(body); + if (instance is IIdentifiable resource && _request.Kind != EndpointKind.Relationship) + { + _resourceDefinitionAccessor.OnDeserialize(resource); + } + AssertResourceIdIsNotTargeted(_targetedFields); return instance; @@ -204,6 +214,8 @@ private OperationContainer ParseForCreateOrUpdateResourceOperation(AtomicOperati IIdentifiable primaryResource = ParseResourceObject(operation.SingleData); + _resourceDefinitionAccessor.OnDeserialize(primaryResource); + request.PrimaryId = primaryResource.StringId; _request.CopyFrom(request); diff --git a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs index fb0b60f5fd..2cdb641861 100644 --- a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs +++ b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs @@ -31,6 +31,7 @@ public class ResponseSerializer : BaseSerializer, IJsonApiSerializer private readonly ILinkBuilder _linkBuilder; private readonly IIncludedResourceObjectBuilder _includedBuilder; private readonly IFieldsToSerialize _fieldsToSerialize; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; private readonly IJsonApiOptions _options; private readonly Type _primaryResourceType; @@ -38,19 +39,22 @@ public class ResponseSerializer : BaseSerializer, IJsonApiSerializer public string ContentType { get; } = HeaderConstants.MediaType; public ResponseSerializer(IMetaBuilder metaBuilder, ILinkBuilder linkBuilder, IIncludedResourceObjectBuilder includedBuilder, - IFieldsToSerialize fieldsToSerialize, IResourceObjectBuilder resourceObjectBuilder, IJsonApiOptions options) + IFieldsToSerialize fieldsToSerialize, IResourceObjectBuilder resourceObjectBuilder, IResourceDefinitionAccessor resourceDefinitionAccessor, + IJsonApiOptions options) : base(resourceObjectBuilder) { ArgumentGuard.NotNull(metaBuilder, nameof(metaBuilder)); ArgumentGuard.NotNull(linkBuilder, nameof(linkBuilder)); ArgumentGuard.NotNull(includedBuilder, nameof(includedBuilder)); ArgumentGuard.NotNull(fieldsToSerialize, nameof(fieldsToSerialize)); + ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); ArgumentGuard.NotNull(options, nameof(options)); _metaBuilder = metaBuilder; _linkBuilder = linkBuilder; _includedBuilder = includedBuilder; _fieldsToSerialize = fieldsToSerialize; + _resourceDefinitionAccessor = resourceDefinitionAccessor; _options = options; _primaryResourceType = typeof(TResource); } @@ -92,6 +96,11 @@ private string SerializeErrorDocument(ErrorDocument errorDocument) /// internal string SerializeSingle(IIdentifiable resource) { + if (resource != null && _fieldsToSerialize.ShouldSerialize) + { + _resourceDefinitionAccessor.OnSerialize(resource); + } + IReadOnlyCollection attributes = _fieldsToSerialize.GetAttributes(_primaryResourceType); IReadOnlyCollection relationships = _fieldsToSerialize.GetRelationships(_primaryResourceType); @@ -119,6 +128,14 @@ internal string SerializeSingle(IIdentifiable resource) /// internal string SerializeMany(IReadOnlyCollection resources) { + if (_fieldsToSerialize.ShouldSerialize) + { + foreach (IIdentifiable resource in resources) + { + _resourceDefinitionAccessor.OnSerialize(resource); + } + } + IReadOnlyCollection attributes = _fieldsToSerialize.GetAttributes(_primaryResourceType); IReadOnlyCollection relationships = _fieldsToSerialize.GetRelationships(_primaryResourceType); diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index d75f6737bd..465dcc13b8 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -34,6 +34,7 @@ public class JsonApiResourceService : IResourceService _resourceChangeTracker; private readonly IResourceHookExecutorFacade _hookExecutor; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; public JsonApiResourceService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, @@ -56,6 +57,10 @@ public JsonApiResourceService(IResourceRepositoryAccessor repositoryAccessor, IQ _resourceChangeTracker = resourceChangeTracker; _hookExecutor = hookExecutor; _traceWriter = new TraceLogWriter>(loggerFactory); + +#pragma warning disable 612 // Method is obsolete + _resourceDefinitionAccessor = queryLayerComposer.GetResourceDefinitionAccessor(); +#pragma warning restore 612 } /// @@ -98,8 +103,7 @@ public virtual async Task GetAsync(TId id, CancellationToken cancella _hookExecutor.BeforeReadSingle(id, ResourcePipeline.GetSingle); - TResource primaryResource = await TryGetPrimaryResourceByIdAsync(id, TopFieldSelection.PreserveExisting, cancellationToken); - AssertPrimaryResourceExists(primaryResource); + TResource primaryResource = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.PreserveExisting, cancellationToken); _hookExecutor.AfterReadSingle(primaryResource, ResourcePipeline.GetSingle); _hookExecutor.OnReturnSingle(primaryResource, ResourcePipeline.GetSingle); @@ -197,6 +201,8 @@ public virtual async Task CreateAsync(TResource resource, Cancellatio _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceForDatabase); + await InitializeResourceAsync(resourceForDatabase, cancellationToken); + try { await _repositoryAccessor.CreateAsync(resourceFromRequest, resourceForDatabase, cancellationToken); @@ -218,10 +224,7 @@ public virtual async Task CreateAsync(TResource resource, Cancellatio throw; } - TResource resourceFromDatabase = - await TryGetPrimaryResourceByIdAsync(resourceForDatabase.Id, TopFieldSelection.WithAllAttributes, cancellationToken); - - AssertPrimaryResourceExists(resourceFromDatabase); + TResource resourceFromDatabase = await GetPrimaryResourceByIdAsync(resourceForDatabase.Id, TopFieldSelection.WithAllAttributes, cancellationToken); _hookExecutor.AfterCreate(resourceFromDatabase); @@ -238,13 +241,19 @@ public virtual async Task CreateAsync(TResource resource, Cancellatio return resourceFromDatabase; } - private async Task AssertResourcesToAssignInRelationshipsExistAsync(TResource resource, CancellationToken cancellationToken) + protected virtual async Task InitializeResourceAsync(TResource resourceForDatabase, CancellationToken cancellationToken) + { + await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceForDatabase, OperationKind.CreateResource, cancellationToken); + } + + protected async Task AssertResourcesToAssignInRelationshipsExistAsync(TResource primaryResource, CancellationToken cancellationToken) { var missingResources = new List(); - foreach ((QueryLayer queryLayer, RelationshipAttribute relationship) in _queryLayerComposer.ComposeForGetTargetedSecondaryResourceIds(resource)) + foreach ((QueryLayer queryLayer, RelationshipAttribute relationship) in _queryLayerComposer.ComposeForGetTargetedSecondaryResourceIds( + primaryResource)) { - object rightValue = relationship.GetValue(resource); + object rightValue = relationship.GetValue(primaryResource); ICollection rightResourceIds = _collectionConverter.ExtractResources(rightValue); IAsyncEnumerable missingResourcesInRelationship = @@ -278,7 +287,7 @@ private async IAsyncEnumerable GetMissingRightRes } /// - public async Task AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds, + public virtual async Task AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new @@ -292,27 +301,22 @@ public async Task AddToToManyRelationshipAsync(TId primaryId, string relationshi AssertHasRelationship(_request.Relationship, relationshipName); - if (secondaryResourceIds.Any()) + if (secondaryResourceIds.Any() && _request.Relationship is HasManyThroughAttribute hasManyThrough) { - if (_request.Relationship is HasManyThroughAttribute hasManyThrough) - { - // In the case of a many-to-many relationship, creating a duplicate entry in the join table results in a - // unique constraint violation. We avoid that by excluding already-existing entries from the set in advance. - await RemoveExistingIdsFromSecondarySetAsync(primaryId, secondaryResourceIds, hasManyThrough, cancellationToken); - } - - try - { - await _repositoryAccessor.AddToToManyRelationshipAsync(primaryId, secondaryResourceIds, cancellationToken); - } - catch (DataStoreUpdateException) - { - TResource primaryResource = await TryGetPrimaryResourceByIdAsync(primaryId, TopFieldSelection.OnlyIdAttribute, cancellationToken); - AssertPrimaryResourceExists(primaryResource); + // In the case of a many-to-many relationship, creating a duplicate entry in the join table results in a + // unique constraint violation. We avoid that by excluding already-existing entries from the set in advance. + await RemoveExistingIdsFromSecondarySetAsync(primaryId, secondaryResourceIds, hasManyThrough, cancellationToken); + } - await AssertResourcesExistAsync(secondaryResourceIds, cancellationToken); - throw; - } + try + { + await _repositoryAccessor.AddToToManyRelationshipAsync(primaryId, secondaryResourceIds, cancellationToken); + } + catch (DataStoreUpdateException) + { + _ = await GetPrimaryResourceByIdAsync(primaryId, TopFieldSelection.OnlyIdAttribute, cancellationToken); + await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken); + throw; } } @@ -331,16 +335,22 @@ private async Task RemoveExistingIdsFromSecondarySetAsync(TId primaryId, ISet secondaryResourceIds, CancellationToken cancellationToken) + protected async Task AssertRightResourcesExistAsync(object rightResourceIds, CancellationToken cancellationToken) { - QueryLayer queryLayer = _queryLayerComposer.ComposeForGetRelationshipRightIds(_request.Relationship, secondaryResourceIds); + ICollection secondaryResourceIds = _collectionConverter.ExtractResources(rightResourceIds); - List missingResources = - await GetMissingRightResourcesAsync(queryLayer, _request.Relationship, secondaryResourceIds, cancellationToken).ToListAsync(cancellationToken); - - if (missingResources.Any()) + if (secondaryResourceIds.Any()) { - throw new ResourcesInRelationshipsNotFoundException(missingResources); + QueryLayer queryLayer = _queryLayerComposer.ComposeForGetRelationshipRightIds(_request.Relationship, secondaryResourceIds); + + List missingResources = + await GetMissingRightResourcesAsync(queryLayer, _request.Relationship, secondaryResourceIds, cancellationToken) + .ToListAsync(cancellationToken); + + if (missingResources.Any()) + { + throw new ResourcesInRelationshipsNotFoundException(missingResources); + } } } @@ -364,6 +374,8 @@ public virtual async Task UpdateAsync(TId id, TResource resource, Can _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); + await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, OperationKind.UpdateResource, cancellationToken); + try { await _repositoryAccessor.UpdateAsync(resourceFromRequest, resourceFromDatabase, cancellationToken); @@ -374,8 +386,7 @@ public virtual async Task UpdateAsync(TId id, TResource resource, Can throw; } - TResource afterResourceFromDatabase = await TryGetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes, cancellationToken); - AssertPrimaryResourceExists(afterResourceFromDatabase); + TResource afterResourceFromDatabase = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes, cancellationToken); _hookExecutor.AfterUpdateResource(afterResourceFromDatabase); @@ -408,6 +419,8 @@ public virtual async Task SetRelationshipAsync(TId primaryId, string relationshi TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(primaryId, cancellationToken); + await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, OperationKind.SetRelationship, cancellationToken); + _hookExecutor.BeforeUpdateRelationship(resourceFromDatabase); try @@ -416,7 +429,7 @@ public virtual async Task SetRelationshipAsync(TId primaryId, string relationshi } catch (DataStoreUpdateException) { - await AssertResourcesExistAsync(_collectionConverter.ExtractResources(secondaryResourceIds), cancellationToken); + await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken); throw; } @@ -439,8 +452,7 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke } catch (DataStoreUpdateException) { - TResource primaryResource = await TryGetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute, cancellationToken); - AssertPrimaryResourceExists(primaryResource); + _ = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute, cancellationToken); throw; } @@ -448,7 +460,7 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke } /// - public async Task RemoveFromToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds, + public virtual async Task RemoveFromToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new @@ -464,12 +476,20 @@ public async Task RemoveFromToManyRelationshipAsync(TId primaryId, string relati AssertHasRelationship(_request.Relationship, relationshipName); TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(primaryId, cancellationToken); - await AssertResourcesExistAsync(secondaryResourceIds, cancellationToken); - if (secondaryResourceIds.Any()) - { - await _repositoryAccessor.RemoveFromToManyRelationshipAsync(resourceFromDatabase, secondaryResourceIds, cancellationToken); - } + await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, OperationKind.RemoveFromRelationship, cancellationToken); + + await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken); + + await _repositoryAccessor.RemoveFromToManyRelationshipAsync(resourceFromDatabase, secondaryResourceIds, cancellationToken); + } + + protected async Task GetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) + { + TResource primaryResource = await TryGetPrimaryResourceByIdAsync(id, fieldSelection, cancellationToken); + AssertPrimaryResourceExists(primaryResource); + + return primaryResource; } private async Task TryGetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) @@ -480,7 +500,7 @@ private async Task TryGetPrimaryResourceByIdAsync(TId id, TopFieldSel return primaryResources.SingleOrDefault(); } - private async Task GetPrimaryResourceForUpdateAsync(TId id, CancellationToken cancellationToken) + protected async Task GetPrimaryResourceForUpdateAsync(TId id, CancellationToken cancellationToken) { QueryLayer queryLayer = _queryLayerComposer.ComposeForUpdate(id, _request.PrimaryResource); var resource = await _repositoryAccessor.GetForUpdateAsync(queryLayer, cancellationToken); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/ArchiveTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/ArchiveTests.cs new file mode 100644 index 0000000000..b7700f5ffd --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/ArchiveTests.cs @@ -0,0 +1,665 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving +{ + public sealed class ArchiveTests : IClassFixture, TelevisionDbContext>> + { + private readonly ExampleIntegrationTestContext, TelevisionDbContext> _testContext; + private readonly TelevisionFakers _fakers = new TelevisionFakers(); + + public ArchiveTests(ExampleIntegrationTestContext, TelevisionDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); + }); + } + + [Fact] + public async Task Can_get_archived_resource_by_ID() + { + // Arrange + TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Broadcasts.Add(broadcast); + await dbContext.SaveChangesAsync(); + }); + + string route = "/televisionBroadcasts/" + broadcast.StringId; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(broadcast.StringId); + responseDocument.SingleData.Attributes["archivedAt"].Should().BeCloseTo(broadcast.ArchivedAt); + } + + [Fact] + public async Task Can_get_unarchived_resource_by_ID() + { + // Arrange + TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); + broadcast.ArchivedAt = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Broadcasts.Add(broadcast); + await dbContext.SaveChangesAsync(); + }); + + string route = "/televisionBroadcasts/" + broadcast.StringId; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(broadcast.StringId); + responseDocument.SingleData.Attributes["archivedAt"].Should().BeNull(); + } + + [Fact] + public async Task Get_primary_resources_excludes_archived() + { + // Arrange + List broadcasts = _fakers.TelevisionBroadcast.Generate(2); + broadcasts[1].ArchivedAt = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Broadcasts.AddRange(broadcasts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/televisionBroadcasts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(broadcasts[1].StringId); + responseDocument.ManyData[0].Attributes["archivedAt"].Should().BeNull(); + } + + [Fact] + public async Task Get_primary_resources_with_filter_includes_archived() + { + // Arrange + List broadcasts = _fakers.TelevisionBroadcast.Generate(2); + broadcasts[1].ArchivedAt = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Broadcasts.AddRange(broadcasts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/televisionBroadcasts?filter=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + responseDocument.ManyData[0].Id.Should().Be(broadcasts[0].StringId); + responseDocument.ManyData[0].Attributes["archivedAt"].Should().BeCloseTo(broadcasts[0].ArchivedAt); + responseDocument.ManyData[1].Id.Should().Be(broadcasts[1].StringId); + responseDocument.ManyData[1].Attributes["archivedAt"].Should().BeNull(); + } + + [Fact] + public async Task Get_primary_resource_by_ID_with_include_excludes_archived() + { + // Arrange + TelevisionStation station = _fakers.TelevisionStation.Generate(); + station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + station.Broadcasts.ElementAt(1).ArchivedAt = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Stations.Add(station); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/televisionStations/{station.StringId}?include=broadcasts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(station.StringId); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); + responseDocument.Included[0].Attributes["archivedAt"].Should().BeNull(); + } + + [Fact] + public async Task Get_primary_resource_by_ID_with_include_and_filter_includes_archived() + { + // Arrange + TelevisionStation station = _fakers.TelevisionStation.Generate(); + station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + station.Broadcasts.ElementAt(1).ArchivedAt = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Stations.Add(station); + await dbContext.SaveChangesAsync(); + }); + + string route = + $"/televisionStations/{station.StringId}?include=broadcasts&filter[broadcasts]=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(station.StringId); + + responseDocument.Included.Should().HaveCount(2); + responseDocument.Included[0].Id.Should().Be(station.Broadcasts.ElementAt(0).StringId); + responseDocument.Included[0].Attributes["archivedAt"].Should().BeCloseTo(station.Broadcasts.ElementAt(0).ArchivedAt); + responseDocument.Included[1].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); + responseDocument.Included[1].Attributes["archivedAt"].Should().BeNull(); + } + + [Fact] + public async Task Get_secondary_resource_includes_archived() + { + // Arrange + BroadcastComment comment = _fakers.BroadcastComment.Generate(); + comment.AppliesTo = _fakers.TelevisionBroadcast.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Comments.Add(comment); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/broadcastComments/{comment.StringId}/appliesTo"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(comment.AppliesTo.StringId); + responseDocument.SingleData.Attributes["archivedAt"].Should().BeCloseTo(comment.AppliesTo.ArchivedAt); + } + + [Fact] + public async Task Get_secondary_resources_excludes_archived() + { + // Arrange + TelevisionStation station = _fakers.TelevisionStation.Generate(); + station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + station.Broadcasts.ElementAt(1).ArchivedAt = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Stations.Add(station); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/televisionStations/{station.StringId}/broadcasts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); + responseDocument.ManyData[0].Attributes["archivedAt"].Should().BeNull(); + } + + [Fact] + public async Task Get_secondary_resources_with_filter_includes_archived() + { + // Arrange + TelevisionStation station = _fakers.TelevisionStation.Generate(); + station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + station.Broadcasts.ElementAt(1).ArchivedAt = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Stations.Add(station); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/televisionStations/{station.StringId}/broadcasts?filter=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + responseDocument.ManyData[0].Id.Should().Be(station.Broadcasts.ElementAt(0).StringId); + responseDocument.ManyData[0].Attributes["archivedAt"].Should().BeCloseTo(station.Broadcasts.ElementAt(0).ArchivedAt); + responseDocument.ManyData[1].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); + responseDocument.ManyData[1].Attributes["archivedAt"].Should().BeNull(); + } + + [Fact] + public async Task Get_secondary_resource_by_ID_with_include_excludes_archived() + { + // Arrange + TelevisionNetwork network = _fakers.TelevisionNetwork.Generate(); + network.Stations = _fakers.TelevisionStation.Generate(1).ToHashSet(); + network.Stations.ElementAt(0).Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + network.Stations.ElementAt(0).Broadcasts.ElementAt(1).ArchivedAt = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Networks.Add(network); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/televisionNetworks/{network.StringId}/stations?include=broadcasts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(network.Stations.ElementAt(0).StringId); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Id.Should().Be(network.Stations.ElementAt(0).Broadcasts.ElementAt(1).StringId); + responseDocument.Included[0].Attributes["archivedAt"].Should().BeNull(); + } + + [Fact] + public async Task Get_secondary_resource_by_ID_with_include_and_filter_includes_archived() + { + TelevisionNetwork network = _fakers.TelevisionNetwork.Generate(); + network.Stations = _fakers.TelevisionStation.Generate(1).ToHashSet(); + network.Stations.ElementAt(0).Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + network.Stations.ElementAt(0).Broadcasts.ElementAt(1).ArchivedAt = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Networks.Add(network); + await dbContext.SaveChangesAsync(); + }); + + string route = + $"/televisionNetworks/{network.StringId}/stations?include=broadcasts&filter[broadcasts]=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(network.Stations.ElementAt(0).StringId); + + responseDocument.Included.Should().HaveCount(2); + responseDocument.Included[0].Id.Should().Be(network.Stations.ElementAt(0).Broadcasts.ElementAt(0).StringId); + responseDocument.Included[0].Attributes["archivedAt"].Should().BeCloseTo(network.Stations.ElementAt(0).Broadcasts.ElementAt(0).ArchivedAt); + responseDocument.Included[1].Id.Should().Be(network.Stations.ElementAt(0).Broadcasts.ElementAt(1).StringId); + responseDocument.Included[1].Attributes["archivedAt"].Should().BeNull(); + } + + [Fact] + public async Task Get_ToMany_relationship_excludes_archived() + { + // Arrange + TelevisionStation station = _fakers.TelevisionStation.Generate(); + station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + station.Broadcasts.ElementAt(1).ArchivedAt = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Stations.Add(station); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/televisionStations/{station.StringId}/relationships/broadcasts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); + } + + [Fact] + public async Task Get_ToMany_relationship_with_filter_includes_archived() + { + // Arrange + TelevisionStation station = _fakers.TelevisionStation.Generate(); + station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + station.Broadcasts.ElementAt(1).ArchivedAt = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Stations.Add(station); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/televisionStations/{station.StringId}/relationships/broadcasts?filter=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + responseDocument.ManyData[0].Id.Should().Be(station.Broadcasts.ElementAt(0).StringId); + responseDocument.ManyData[1].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); + } + + [Fact] + public async Task Can_create_unarchived_resource() + { + // Arrange + TelevisionBroadcast newBroadcast = _fakers.TelevisionBroadcast.Generate(); + + var requestBody = new + { + data = new + { + type = "televisionBroadcasts", + attributes = new + { + title = newBroadcast.Title, + airedAt = newBroadcast.AiredAt + } + } + }; + + const string route = "/televisionBroadcasts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["title"].Should().Be(newBroadcast.Title); + responseDocument.SingleData.Attributes["airedAt"].Should().BeCloseTo(newBroadcast.AiredAt); + responseDocument.SingleData.Attributes["archivedAt"].Should().BeNull(); + } + + [Fact] + public async Task Cannot_create_archived_resource() + { + // Arrange + TelevisionBroadcast newBroadcast = _fakers.TelevisionBroadcast.Generate(); + + var requestBody = new + { + data = new + { + type = "televisionBroadcasts", + attributes = new + { + title = newBroadcast.Title, + airedAt = newBroadcast.AiredAt, + archivedAt = newBroadcast.ArchivedAt + } + } + }; + + const string route = "/televisionBroadcasts"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("Television broadcasts cannot be created in archived state."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Can_archive_resource() + { + // Arrange + TelevisionBroadcast existingBroadcast = _fakers.TelevisionBroadcast.Generate(); + existingBroadcast.ArchivedAt = null; + + DateTimeOffset newArchivedAt = _fakers.TelevisionBroadcast.Generate().ArchivedAt!.Value; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Broadcasts.Add(existingBroadcast); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "televisionBroadcasts", + id = existingBroadcast.StringId, + attributes = new + { + archivedAt = newArchivedAt + } + } + }; + + string route = "/televisionBroadcasts/" + existingBroadcast.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TelevisionBroadcast broadcastInDatabase = await dbContext.Broadcasts.FirstWithIdAsync(existingBroadcast.Id); + + broadcastInDatabase.ArchivedAt.Should().BeCloseTo(newArchivedAt); + }); + } + + [Fact] + public async Task Can_unarchive_resource() + { + // Arrange + TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Broadcasts.Add(broadcast); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "televisionBroadcasts", + id = broadcast.StringId, + attributes = new + { + archivedAt = (DateTimeOffset?)null + } + } + }; + + string route = "/televisionBroadcasts/" + broadcast.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TelevisionBroadcast broadcastInDatabase = await dbContext.Broadcasts.FirstWithIdAsync(broadcast.Id); + + broadcastInDatabase.ArchivedAt.Should().BeNull(); + }); + } + + [Fact] + public async Task Cannot_shift_archive_date() + { + // Arrange + TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); + + DateTimeOffset? newArchivedAt = _fakers.TelevisionBroadcast.Generate().ArchivedAt; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Broadcasts.Add(broadcast); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "televisionBroadcasts", + id = broadcast.StringId, + attributes = new + { + archivedAt = newArchivedAt + } + } + }; + + string route = "/televisionBroadcasts/" + broadcast.StringId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("Archive date of television broadcasts cannot be shifted. Unarchive it first."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Can_delete_archived_resource() + { + // Arrange + TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Broadcasts.Add(broadcast); + await dbContext.SaveChangesAsync(); + }); + + string route = "/televisionBroadcasts/" + broadcast.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TelevisionBroadcast broadcastInDatabase = await dbContext.Broadcasts.FirstWithIdOrDefaultAsync(broadcast.Id); + + broadcastInDatabase.Should().BeNull(); + }); + } + + [Fact] + public async Task Cannot_delete_unarchived_resource() + { + // Arrange + TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); + broadcast.ArchivedAt = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Broadcasts.Add(broadcast); + await dbContext.SaveChangesAsync(); + }); + + string route = "/televisionBroadcasts/" + broadcast.StringId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("Television broadcasts must first be archived before they can be deleted."); + error.Detail.Should().BeNull(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/BroadcastComment.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/BroadcastComment.cs new file mode 100644 index 0000000000..d0d244681e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/BroadcastComment.cs @@ -0,0 +1,20 @@ +using System; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class BroadcastComment : Identifiable + { + [Attr] + public string Text { get; set; } + + [Attr] + public DateTimeOffset CreatedAt { get; set; } + + [HasOne] + public TelevisionBroadcast AppliesTo { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/BroadcastCommentsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/BroadcastCommentsController.cs new file mode 100644 index 0000000000..e6d5e3d1b6 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/BroadcastCommentsController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving +{ + public sealed class BroadcastCommentsController : JsonApiController + { + public BroadcastCommentsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcast.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcast.cs new file mode 100644 index 0000000000..97e3c3f4be --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcast.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class TelevisionBroadcast : Identifiable + { + [Attr] + public string Title { get; set; } + + [Attr] + public DateTimeOffset AiredAt { get; set; } + + [Attr] + public DateTimeOffset? ArchivedAt { get; set; } + + [HasOne] + public TelevisionStation AiredOn { get; set; } + + [HasMany] + public ISet Comments { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs new file mode 100644 index 0000000000..2ccefe3670 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public sealed class TelevisionBroadcastDefinition : JsonApiResourceDefinition + { + private readonly TelevisionDbContext _dbContext; + private readonly IJsonApiRequest _request; + private readonly IEnumerable _constraintProviders; + private readonly ResourceContext _broadcastContext; + + private DateTimeOffset? _storedArchivedAt; + + public TelevisionBroadcastDefinition(IResourceGraph resourceGraph, TelevisionDbContext dbContext, IJsonApiRequest request, + IEnumerable constraintProviders) + : base(resourceGraph) + { + _dbContext = dbContext; + _request = request; + _constraintProviders = constraintProviders; + _broadcastContext = resourceGraph.GetResourceContext(); + } + + public override FilterExpression OnApplyFilter(FilterExpression existingFilter) + { + if (_request.IsReadOnly) + { + // Rule: hide archived broadcasts in collections, unless a filter is specified. + + if (IsReturningCollectionOfTelevisionBroadcasts() && !HasFilterOnArchivedAt(existingFilter)) + { + AttrAttribute archivedAtAttribute = + _broadcastContext.Attributes.Single(attr => attr.Property.Name == nameof(TelevisionBroadcast.ArchivedAt)); + + var archivedAtChain = new ResourceFieldChainExpression(archivedAtAttribute); + + FilterExpression isUnarchived = new ComparisonExpression(ComparisonOperator.Equals, archivedAtChain, new NullConstantExpression()); + + return existingFilter == null + ? isUnarchived + : new LogicalExpression(LogicalOperator.And, ArrayFactory.Create(existingFilter, isUnarchived)); + } + } + + return base.OnApplyFilter(existingFilter); + } + + private bool IsReturningCollectionOfTelevisionBroadcasts() + { + return IsRequestingCollectionOfTelevisionBroadcasts() || IsIncludingCollectionOfTelevisionBroadcasts(); + } + + private bool IsRequestingCollectionOfTelevisionBroadcasts() + { + if (_request.IsCollection) + { + if (_request.PrimaryResource == _broadcastContext || _request.SecondaryResource == _broadcastContext) + { + return true; + } + } + + return false; + } + + private bool IsIncludingCollectionOfTelevisionBroadcasts() + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + IncludeElementExpression[] includeElements = _constraintProviders + .SelectMany(provider => provider.GetConstraints()) + .Select(expressionInScope => expressionInScope.Expression) + .OfType() + .SelectMany(include => include.Elements) + .ToArray(); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + foreach (IncludeElementExpression includeElement in includeElements) + { + if (includeElement.Relationship is HasManyAttribute && includeElement.Relationship.RightType == _broadcastContext.ResourceType) + { + return true; + } + } + + return false; + } + + private bool HasFilterOnArchivedAt(FilterExpression existingFilter) + { + if (existingFilter == null) + { + return false; + } + + var walker = new FilterWalker(); + walker.Visit(existingFilter, null); + + return walker.HasFilterOnArchivedAt; + } + + public override Task OnPrepareWriteAsync(TelevisionBroadcast broadcast, OperationKind operationKind, CancellationToken cancellationToken) + { + if (operationKind == OperationKind.UpdateResource) + { + _storedArchivedAt = broadcast.ArchivedAt; + } + + return base.OnPrepareWriteAsync(broadcast, operationKind, cancellationToken); + } + + public override async Task OnWritingAsync(TelevisionBroadcast broadcast, OperationKind operationKind, CancellationToken cancellationToken) + { + if (operationKind == OperationKind.CreateResource) + { + AssertIsNotArchived(broadcast); + } + else if (operationKind == OperationKind.UpdateResource) + { + AssertIsNotShiftingArchiveDate(broadcast); + } + else if (operationKind == OperationKind.DeleteResource) + { + TelevisionBroadcast broadcastToDelete = + await _dbContext.Broadcasts.FirstOrDefaultAsync(resource => resource.Id == broadcast.Id, cancellationToken); + + if (broadcastToDelete != null) + { + AssertIsArchived(broadcastToDelete); + } + } + + await base.OnWritingAsync(broadcast, operationKind, cancellationToken); + } + + [AssertionMethod] + private static void AssertIsNotArchived(TelevisionBroadcast broadcast) + { + if (broadcast.ArchivedAt != null) + { + throw new JsonApiException(new Error(HttpStatusCode.Forbidden) + { + Title = "Television broadcasts cannot be created in archived state." + }); + } + } + + [AssertionMethod] + private void AssertIsNotShiftingArchiveDate(TelevisionBroadcast broadcast) + { + if (_storedArchivedAt != null && broadcast.ArchivedAt != null && _storedArchivedAt != broadcast.ArchivedAt) + { + throw new JsonApiException(new Error(HttpStatusCode.Forbidden) + { + Title = "Archive date of television broadcasts cannot be shifted. Unarchive it first." + }); + } + } + + [AssertionMethod] + private static void AssertIsArchived(TelevisionBroadcast broadcast) + { + if (broadcast.ArchivedAt == null) + { + throw new JsonApiException(new Error(HttpStatusCode.Forbidden) + { + Title = "Television broadcasts must first be archived before they can be deleted." + }); + } + } + + private sealed class FilterWalker : QueryExpressionRewriter + { + public bool HasFilterOnArchivedAt { get; private set; } + + public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, object argument) + { + if (expression.Fields.First().Property.Name == nameof(TelevisionBroadcast.ArchivedAt)) + { + HasFilterOnArchivedAt = true; + } + + return base.VisitResourceFieldChain(expression, argument); + } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastsController.cs new file mode 100644 index 0000000000..cc6575523c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastsController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving +{ + public sealed class TelevisionBroadcastsController : JsonApiController + { + public TelevisionBroadcastsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionDbContext.cs new file mode 100644 index 0000000000..8bc71dea0d --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionDbContext.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class TelevisionDbContext : DbContext + { + public DbSet Networks { get; set; } + public DbSet Stations { get; set; } + public DbSet Broadcasts { get; set; } + public DbSet Comments { get; set; } + + public TelevisionDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionFakers.cs new file mode 100644 index 0000000000..288c75bdbf --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionFakers.cs @@ -0,0 +1,40 @@ +using System; +using Bogus; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_always +// @formatter:keep_existing_linebreaks true + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving +{ + internal sealed class TelevisionFakers : FakerContainer + { + private readonly Lazy> _lazyTelevisionNetworkFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(network => network.Name, faker => faker.Company.CompanyName())); + + private readonly Lazy> _lazyTelevisionStationFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(station => station.Name, faker => faker.Company.CompanyName())); + + private readonly Lazy> _lazyTelevisionBroadcastFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(broadcast => broadcast.Title, faker => faker.Lorem.Sentence()) + .RuleFor(broadcast => broadcast.AiredAt, faker => faker.Date.PastOffset()) + .RuleFor(broadcast => broadcast.ArchivedAt, faker => faker.Date.RecentOffset())); + + private readonly Lazy> _lazyBroadcastCommentFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(comment => comment.Text, faker => faker.Lorem.Paragraph()) + .RuleFor(comment => comment.CreatedAt, faker => faker.Date.PastOffset())); + + public Faker TelevisionNetwork => _lazyTelevisionNetworkFaker.Value; + public Faker TelevisionStation => _lazyTelevisionStationFaker.Value; + public Faker TelevisionBroadcast => _lazyTelevisionBroadcastFaker.Value; + public Faker BroadcastComment => _lazyBroadcastCommentFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionNetwork.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionNetwork.cs new file mode 100644 index 0000000000..3d5c213d31 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionNetwork.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class TelevisionNetwork : Identifiable + { + [Attr] + public string Name { get; set; } + + [HasMany] + public ISet Stations { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionNetworksController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionNetworksController.cs new file mode 100644 index 0000000000..f89eef1f92 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionNetworksController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving +{ + public sealed class TelevisionNetworksController : JsonApiController + { + public TelevisionNetworksController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionStation.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionStation.cs new file mode 100644 index 0000000000..8087ceff75 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionStation.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class TelevisionStation : Identifiable + { + [Attr] + public string Name { get; set; } + + [HasMany] + public ISet Broadcasts { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionStationsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionStationsController.cs new file mode 100644 index 0000000000..490778b789 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionStationsController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving +{ + public sealed class TelevisionStationsController : JsonApiController + { + public TelevisionStationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs index de71c07d49..453cd86714 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs @@ -1,14 +1,12 @@ -using System; using System.Net; using System.Net.Http; using System.Threading.Tasks; using FluentAssertions; using FluentAssertions.Extensions; -using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample.Controllers; using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; @@ -27,8 +25,8 @@ public AtomicResourceMetaTests(ExampleIntegrationTestContext { - services.AddScoped, MusicTrackMetaDefinition>(); - services.AddScoped, TextLanguageMetaDefinition>(); + services.AddResourceDefinition(); + services.AddResourceDefinition(); }); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs index ebcf1fe882..63e0d007e8 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs @@ -6,7 +6,6 @@ using FluentAssertions; using FluentAssertions.Extensions; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample.Controllers; using JsonApiDotNetCoreExampleTests.Startups; @@ -38,7 +37,7 @@ public AtomicQueryStringTests(ExampleIntegrationTestContext, MusicTrackReleaseDefinition>(); + services.AddResourceDefinition(); }); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationHitCounter.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationHitCounter.cs new file mode 100644 index 0000000000..baff791fbc --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationHitCounter.cs @@ -0,0 +1,24 @@ +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions.Serialization +{ + public sealed class AtomicSerializationHitCounter + { + internal int DeserializeCount { get; private set; } + internal int SerializeCount { get; private set; } + + internal void Reset() + { + DeserializeCount = 0; + SerializeCount = 0; + } + + internal void IncrementDeserializeCount() + { + DeserializeCount++; + } + + internal void IncrementSerializeCount() + { + SerializeCount++; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs new file mode 100644 index 0000000000..f7a71cfd6b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs @@ -0,0 +1,367 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExample.Controllers; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions.Serialization +{ + public sealed class AtomicSerializationResourceDefinitionTests + : IClassFixture, OperationsDbContext>> + { + private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new OperationsFakers(); + + public AtomicSerializationResourceDefinitionTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); + + services.AddSingleton(); + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); + + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + hitCounter.Reset(); + } + + [Fact] + public async Task Transforms_on_create_resource_with_side_effects() + { + // Arrange + List newCompanies = _fakers.RecordCompany.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "recordCompanies", + attributes = new + { + name = newCompanies[0].Name, + countryOfResidence = newCompanies[0].CountryOfResidence + } + } + }, + new + { + op = "add", + data = new + { + type = "recordCompanies", + attributes = new + { + name = newCompanies[1].Name, + countryOfResidence = newCompanies[1].CountryOfResidence + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = + await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(2); + + responseDocument.Results[0].SingleData.Attributes["name"].Should().Be(newCompanies[0].Name.ToUpperInvariant()); + responseDocument.Results[0].SingleData.Attributes["countryOfResidence"].Should().Be(newCompanies[0].CountryOfResidence.ToUpperInvariant()); + + responseDocument.Results[1].SingleData.Attributes["name"].Should().Be(newCompanies[1].Name.ToUpperInvariant()); + responseDocument.Results[1].SingleData.Attributes["countryOfResidence"].Should().Be(newCompanies[1].CountryOfResidence.ToUpperInvariant()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); + companiesInDatabase.Should().HaveCount(2); + + companiesInDatabase[0].Name.Should().Be(newCompanies[0].Name.ToUpperInvariant()); + companiesInDatabase[0].CountryOfResidence.Should().Be(newCompanies[0].CountryOfResidence); + + companiesInDatabase[1].Name.Should().Be(newCompanies[1].Name.ToUpperInvariant()); + companiesInDatabase[1].CountryOfResidence.Should().Be(newCompanies[1].CountryOfResidence); + }); + + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + hitCounter.DeserializeCount.Should().Be(2); + hitCounter.SerializeCount.Should().Be(2); + } + + [Fact] + public async Task Skips_on_create_resource_with_ToOne_relationship() + { + // Arrange + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + + string 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 + } + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = + await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(0); + } + + [Fact] + public async Task Transforms_on_update_resource_with_side_effects() + { + // Arrange + List existingCompanies = _fakers.RecordCompany.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.RecordCompanies.AddRange(existingCompanies); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "recordCompanies", + id = existingCompanies[0].StringId, + attributes = new + { + } + } + }, + new + { + op = "update", + data = new + { + type = "recordCompanies", + id = existingCompanies[1].StringId, + attributes = new + { + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = + await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(2); + + responseDocument.Results[0].SingleData.Attributes["name"].Should().Be(existingCompanies[0].Name); + responseDocument.Results[0].SingleData.Attributes["countryOfResidence"].Should().Be(existingCompanies[0].CountryOfResidence.ToUpperInvariant()); + + responseDocument.Results[1].SingleData.Attributes["name"].Should().Be(existingCompanies[1].Name); + responseDocument.Results[1].SingleData.Attributes["countryOfResidence"].Should().Be(existingCompanies[1].CountryOfResidence.ToUpperInvariant()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); + companiesInDatabase.Should().HaveCount(2); + + companiesInDatabase[0].Name.Should().Be(existingCompanies[0].Name); + companiesInDatabase[0].CountryOfResidence.Should().Be(existingCompanies[0].CountryOfResidence); + + companiesInDatabase[1].Name.Should().Be(existingCompanies[1].Name); + companiesInDatabase[1].CountryOfResidence.Should().Be(existingCompanies[1].CountryOfResidence); + }); + + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + hitCounter.DeserializeCount.Should().Be(2); + hitCounter.SerializeCount.Should().Be(2); + } + + [Fact] + public async Task Skips_on_update_resource_with_ToOne_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + RecordCompany 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, + attributes = new + { + }, + relationships = new + { + ownedBy = new + { + data = new + { + type = "recordCompanies", + id = existingCompany.StringId + } + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = + await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(0); + } + + [Fact] + public async Task Skips_on_update_ToOne_relationship() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + RecordCompany 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 + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(0); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs new file mode 100644 index 0000000000..2d899b27b3 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs @@ -0,0 +1,38 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions.Serialization +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public sealed class RecordCompanyDefinition : JsonApiResourceDefinition + { + private readonly AtomicSerializationHitCounter _hitCounter; + + public RecordCompanyDefinition(IResourceGraph resourceGraph, AtomicSerializationHitCounter hitCounter) + : base(resourceGraph) + { + _hitCounter = hitCounter; + } + + public override void OnDeserialize(RecordCompany resource) + { + _hitCounter.IncrementDeserializeCount(); + + if (!string.IsNullOrEmpty(resource.Name)) + { + resource.Name = resource.Name.ToUpperInvariant(); + } + } + + public override void OnSerialize(RecordCompany resource) + { + _hitCounter.IncrementSerializeCount(); + + if (!string.IsNullOrEmpty(resource.CountryOfResidence)) + { + resource.CountryOfResidence = resource.CountryOfResidence.ToUpperInvariant(); + } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/AtomicSparseFieldSetResourceDefinitionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs similarity index 97% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/AtomicSparseFieldSetResourceDefinitionTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs index 144c0df61a..b745a39f7b 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/AtomicSparseFieldSetResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs @@ -3,6 +3,7 @@ using System.Net.Http; using System.Threading.Tasks; using FluentAssertions; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExample.Controllers; @@ -11,7 +12,7 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions.SparseFieldSets { public sealed class AtomicSparseFieldSetResourceDefinitionTests : IClassFixture, OperationsDbContext>> @@ -28,7 +29,7 @@ public AtomicSparseFieldSetResourceDefinitionTests(ExampleIntegrationTestContext testContext.ConfigureServicesAfterStartup(services => { services.AddSingleton(); - services.AddScoped, LyricTextDefinition>(); + services.AddResourceDefinition(); services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); }); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/LyricPermissionProvider.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricPermissionProvider.cs similarity index 82% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/LyricPermissionProvider.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricPermissionProvider.cs index 07140f6fa1..63620c991a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/LyricPermissionProvider.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricPermissionProvider.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions.SparseFieldSets { public sealed class LyricPermissionProvider { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/LyricTextDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs similarity index 96% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/LyricTextDefinition.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs index bf89c79b70..7603fd4588 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/LyricTextDefinition.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions.SparseFieldSets { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class LyricTextDefinition : JsonApiResourceDefinition diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs index b941ff28f4..f6703e7272 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs @@ -470,7 +470,6 @@ public async Task Cannot_delete_missing_resource() error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("The requested resource does not exist."); error.Detail.Should().Be($"Resource of type 'bankAccounts' with ID '{stringId}' does not exist."); - error.Source.Parameter.Should().BeNull(); } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs similarity index 92% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateDbContext.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs index 7e76eb5617..0805cc34f9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs @@ -3,7 +3,7 @@ // @formatter:wrap_chained_method_calls chop_always -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.ModelState { [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class ModelStateDbContext : DbContext diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs index ac56b7c0fe..a59f88d766 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs @@ -8,7 +8,7 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.ModelState { public sealed class ModelStateValidationTests : IClassFixture, ModelStateDbContext>> diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs similarity index 97% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs index cf30a79101..7157e70491 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/NoModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs @@ -7,7 +7,7 @@ using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.ModelState { public sealed class NoModelStateValidationTests : IClassFixture, ModelStateDbContext>> { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemDirectoriesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemDirectoriesController.cs similarity index 85% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemDirectoriesController.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemDirectoriesController.cs index 63ee816e0d..639c319d7c 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemDirectoriesController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemDirectoriesController.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.ModelState { public sealed class SystemDirectoriesController : JsonApiController { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemDirectory.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs similarity index 93% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemDirectory.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs index d291370ae6..4504dd6e13 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemDirectory.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs @@ -4,7 +4,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.ModelState { [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class SystemDirectory : Identifiable diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemFile.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs similarity index 86% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemFile.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs index 8598695d35..1a66473c8d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemFile.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.ModelState { [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class SystemFile : Identifiable diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemFilesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemFilesController.cs similarity index 84% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemFilesController.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemFilesController.cs index 0a18e248f1..425445b6ad 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ModelStateValidation/SystemFilesController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemFilesController.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ModelStateValidation +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.ModelState { public sealed class SystemFilesController : JsonApiController { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/Workflow.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/Workflow.cs new file mode 100644 index 0000000000..1cd1e3b783 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/Workflow.cs @@ -0,0 +1,14 @@ +using System; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.RequestBody +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class Workflow : Identifiable + { + [Attr] + public WorkflowStage Stage { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs new file mode 100644 index 0000000000..a54ac87406 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs @@ -0,0 +1,18 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +// @formatter:wrap_chained_method_calls chop_always + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.RequestBody +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class WorkflowDbContext : DbContext + { + public DbSet Workflows { get; set; } + + public WorkflowDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs new file mode 100644 index 0000000000..7c0b59c53b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.RequestBody +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public sealed class WorkflowDefinition : JsonApiResourceDefinition + { + private static readonly Dictionary> StageTransitionTable = + new Dictionary> + { + [WorkflowStage.Created] = new[] + { + WorkflowStage.InProgress + }, + [WorkflowStage.InProgress] = new[] + { + WorkflowStage.OnHold, + WorkflowStage.Succeeded, + WorkflowStage.Failed, + WorkflowStage.Canceled + }, + [WorkflowStage.OnHold] = new[] + { + WorkflowStage.InProgress, + WorkflowStage.Canceled + } + }; + + private WorkflowStage _previousStage; + + public WorkflowDefinition(IResourceGraph resourceGraph) + : base(resourceGraph) + { + } + + public override Task OnPrepareWriteAsync(Workflow resource, OperationKind operationKind, CancellationToken cancellationToken) + { + if (operationKind == OperationKind.UpdateResource) + { + _previousStage = resource.Stage; + } + + return Task.CompletedTask; + } + + public override Task OnWritingAsync(Workflow resource, OperationKind operationKind, CancellationToken cancellationToken) + { + if (operationKind == OperationKind.CreateResource) + { + AssertHasValidInitialStage(resource); + } + else if (operationKind == OperationKind.UpdateResource && resource.Stage != _previousStage) + { + AssertCanTransitionToStage(_previousStage, resource.Stage); + } + + return Task.CompletedTask; + } + + [AssertionMethod] + private static void AssertHasValidInitialStage(Workflow resource) + { + if (resource.Stage != WorkflowStage.Created) + { + throw new JsonApiException(new Error(HttpStatusCode.UnprocessableEntity) + { + Title = "Invalid workflow stage.", + Detail = $"Initial stage of workflow must be '{WorkflowStage.Created}'.", + Source = + { + Pointer = "/data/attributes/stage" + } + }); + } + } + + [AssertionMethod] + private static void AssertCanTransitionToStage(WorkflowStage fromStage, WorkflowStage toStage) + { + if (!CanTransitionToStage(fromStage, toStage)) + { + throw new JsonApiException(new Error(HttpStatusCode.UnprocessableEntity) + { + Title = "Invalid workflow stage.", + Detail = $"Cannot transition from '{fromStage}' to '{toStage}'.", + Source = + { + Pointer = "/data/attributes/stage" + } + }); + } + } + + private static bool CanTransitionToStage(WorkflowStage fromStage, WorkflowStage toStage) + { + if (StageTransitionTable.ContainsKey(fromStage)) + { + ICollection possibleNextStages = StageTransitionTable[fromStage]; + return possibleNextStages.Contains(toStage); + } + + return false; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowStage.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowStage.cs new file mode 100644 index 0000000000..67602b4483 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowStage.cs @@ -0,0 +1,12 @@ +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.RequestBody +{ + public enum WorkflowStage + { + Created, + InProgress, + OnHold, + Succeeded, + Failed, + Canceled + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs new file mode 100644 index 0000000000..ec6bfbdeff --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs @@ -0,0 +1,173 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.RequestBody +{ + public sealed class WorkflowTests : IClassFixture, WorkflowDbContext>> + { + private readonly ExampleIntegrationTestContext, WorkflowDbContext> _testContext; + + public WorkflowTests(ExampleIntegrationTestContext, WorkflowDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); + }); + } + + [Fact] + public async Task Can_create_in_valid_stage() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workflows", + attributes = new + { + stage = WorkflowStage.Created + } + } + }; + + const string route = "/workflows"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + } + + [Fact] + public async Task Cannot_create_in_invalid_stage() + { + // Arrange + var requestBody = new + { + data = new + { + type = "workflows", + attributes = new + { + stage = WorkflowStage.Canceled + } + } + }; + + const string route = "/workflows"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Invalid workflow stage."); + error.Detail.Should().Be("Initial stage of workflow must be 'Created'."); + error.Source.Pointer.Should().Be("/data/attributes/stage"); + } + + [Fact] + public async Task Cannot_transition_to_invalid_stage() + { + // Arrange + var existingWorkflow = new Workflow + { + Stage = WorkflowStage.OnHold + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Workflows.Add(existingWorkflow); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workflows", + id = existingWorkflow.StringId, + attributes = new + { + stage = WorkflowStage.Succeeded + } + } + }; + + string route = "/workflows/" + existingWorkflow.StringId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Invalid workflow stage."); + error.Detail.Should().Be("Cannot transition from 'OnHold' to 'Succeeded'."); + error.Source.Pointer.Should().Be("/data/attributes/stage"); + } + + [Fact] + public async Task Can_transition_to_valid_stage() + { + // Arrange + var existingWorkflow = new Workflow + { + Stage = WorkflowStage.InProgress + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Workflows.Add(existingWorkflow); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "workflows", + id = existingWorkflow.StringId, + attributes = new + { + stage = WorkflowStage.Failed + } + } + }; + + string route = "/workflows/" + existingWorkflow.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowsController.cs new file mode 100644 index 0000000000..8edd74c51c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowsController.cs @@ -0,0 +1,16 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.RequestBody +{ + public sealed class WorkflowsController : JsonApiController + { + public WorkflowsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs index 0378db3434..bdf5efec57 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs @@ -3,10 +3,9 @@ using System.Net.Http; using System.Threading.Tasks; using FluentAssertions; -using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; @@ -26,7 +25,7 @@ public ResourceMetaTests(ExampleIntegrationTestContext { - services.AddScoped, SupportTicketDefinition>(); + services.AddResourceDefinition(); }); } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainFakers.cs new file mode 100644 index 0000000000..9e69ec8689 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainFakers.cs @@ -0,0 +1,26 @@ +using System; +using Bogus; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_always +// @formatter:keep_existing_linebreaks true + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices +{ + internal sealed class DomainFakers : FakerContainer + { + private readonly Lazy> _lazyDomainUserFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(domainUser => domainUser.LoginName, faker => faker.Person.UserName) + .RuleFor(domainUser => domainUser.DisplayName, faker => faker.Person.FullName)); + + private readonly Lazy> _lazyDomainGroupFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(domainGroup => domainGroup.Name, faker => faker.Commerce.Department())); + + public Faker DomainUser => _lazyDomainUserFaker.Value; + public Faker DomainGroup => _lazyDomainGroupFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainGroup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainGroup.cs new file mode 100644 index 0000000000..306aa2c907 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainGroup.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class DomainGroup : Identifiable + { + [Attr] + public string Name { get; set; } + + [HasMany] + public ISet Users { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainGroupsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainGroupsController.cs new file mode 100644 index 0000000000..efd737881b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainGroupsController.cs @@ -0,0 +1,16 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices +{ + public sealed class DomainGroupsController : JsonApiController + { + public DomainGroupsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainUser.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainUser.cs new file mode 100644 index 0000000000..538b2a31bd --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainUser.cs @@ -0,0 +1,22 @@ +using System; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class DomainUser : Identifiable + { + [Attr] + [Required] + public string LoginName { get; set; } + + [Attr] + public string DisplayName { get; set; } + + [HasOne] + public DomainGroup Group { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainUsersController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainUsersController.cs new file mode 100644 index 0000000000..48b26e2acd --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainUsersController.cs @@ -0,0 +1,16 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices +{ + public sealed class DomainUsersController : JsonApiController + { + public DomainUsersController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs new file mode 100644 index 0000000000..259bafb87e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs @@ -0,0 +1,17 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.FireAndForgetDelivery +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class FireForgetDbContext : DbContext + { + public DbSet Users { get; set; } + public DbSet Groups { get; set; } + + public FireForgetDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs new file mode 100644 index 0000000000..f0faad036c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.FireAndForgetDelivery +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public sealed class FireForgetGroupDefinition : MessagingGroupDefinition + { + private readonly MessageBroker _messageBroker; + private DomainGroup _groupToDelete; + + public FireForgetGroupDefinition(IResourceGraph resourceGraph, FireForgetDbContext dbContext, MessageBroker messageBroker) + : base(resourceGraph, dbContext.Users, dbContext.Groups) + { + _messageBroker = messageBroker; + } + + public override async Task OnWritingAsync(DomainGroup group, OperationKind operationKind, CancellationToken cancellationToken) + { + if (operationKind == OperationKind.DeleteResource) + { + _groupToDelete = await base.GetGroupToDeleteAsync(group.Id, cancellationToken); + } + } + + public override Task OnWriteSucceededAsync(DomainGroup group, OperationKind operationKind, CancellationToken cancellationToken) + { + return FinishWriteAsync(group, operationKind, cancellationToken); + } + + protected override Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) + { + return _messageBroker.PostMessageAsync(message, cancellationToken); + } + + protected override Task GetGroupToDeleteAsync(Guid groupId, CancellationToken cancellationToken) + { + return Task.FromResult(_groupToDelete); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs new file mode 100644 index 0000000000..452d267128 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs @@ -0,0 +1,506 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.FireAndForgetDelivery +{ + public sealed partial class FireForgetTests + { + [Fact] + public async Task Create_group_sends_messages() + { + // Arrange + string newGroupName = _fakers.DomainGroup.Generate().Name; + + var requestBody = new + { + data = new + { + type = "domainGroups", + attributes = new + { + name = newGroupName + } + } + }; + + const string route = "/domainGroups"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["name"].Should().Be(newGroupName); + + Guid newGroupId = Guid.Parse(responseDocument.SingleData.Id); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(1); + + var content = messageBroker.SentMessages[0].GetContentAs(); + content.GroupId.Should().Be(newGroupId); + content.GroupName.Should().Be(newGroupName); + } + + [Fact] + public async Task Create_group_with_users_sends_messages() + { + // Arrange + DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + + string newGroupName = _fakers.DomainGroup.Generate().Name; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithOtherGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainGroups", + attributes = new + { + name = newGroupName + }, + relationships = new + { + users = new + { + data = new[] + { + new + { + type = "domainUsers", + id = existingUserWithoutGroup.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithOtherGroup.StringId + } + } + } + } + } + }; + + const string route = "/domainGroups"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["name"].Should().Be(newGroupName); + + Guid newGroupId = Guid.Parse(responseDocument.SingleData.Id); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(3); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.GroupId.Should().Be(newGroupId); + content1.GroupName.Should().Be(newGroupName); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUserWithoutGroup.Id); + content2.GroupId.Should().Be(newGroupId); + + var content3 = messageBroker.SentMessages[2].GetContentAs(); + content3.UserId.Should().Be(existingUserWithOtherGroup.Id); + content3.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); + content3.AfterGroupId.Should().Be(newGroupId); + } + + [Fact] + public async Task Update_group_sends_messages() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + string newGroupName = _fakers.DomainGroup.Generate().Name; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId, + attributes = new + { + name = newGroupName + } + } + }; + + string route = "/domainGroups/" + existingGroup.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(1); + + var content = messageBroker.SentMessages[0].GetContentAs(); + content.GroupId.Should().Be(existingGroup.StringId); + content.BeforeGroupName.Should().Be(existingGroup.Name); + content.AfterGroupName.Should().Be(newGroupName); + } + + [Fact] + public async Task Update_group_with_users_sends_messages() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + + DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup1.Group = existingGroup; + + DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup2.Group = existingGroup; + + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup1, existingUserWithSameGroup2, existingUserWithOtherGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId, + relationships = new + { + users = new + { + data = new[] + { + new + { + type = "domainUsers", + id = existingUserWithoutGroup.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithSameGroup1.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithOtherGroup.StringId + } + } + } + } + } + }; + + string route = "/domainGroups/" + existingGroup.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(3); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingUserWithoutGroup.Id); + content1.GroupId.Should().Be(existingGroup.Id); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUserWithOtherGroup.Id); + content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); + content2.AfterGroupId.Should().Be(existingGroup.Id); + + var content3 = messageBroker.SentMessages[2].GetContentAs(); + content3.UserId.Should().Be(existingUserWithSameGroup2.Id); + content3.GroupId.Should().Be(existingGroup.Id); + } + + [Fact] + public async Task Delete_group_sends_messages() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + string route = "/domainGroups/" + existingGroup.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(1); + + var content = messageBroker.SentMessages[0].GetContentAs(); + content.GroupId.Should().Be(existingGroup.StringId); + } + + [Fact] + public async Task Delete_group_with_users_sends_messages() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + existingGroup.Users = _fakers.DomainUser.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + string route = "/domainGroups/" + existingGroup.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(2); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingGroup.Users.ElementAt(0).Id); + content1.GroupId.Should().Be(existingGroup.StringId); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.GroupId.Should().Be(existingGroup.StringId); + } + + [Fact] + public async Task Replace_users_in_group_sends_messages() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + + DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup1.Group = existingGroup; + + DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup2.Group = existingGroup; + + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup1, existingUserWithSameGroup2, existingUserWithOtherGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "domainUsers", + id = existingUserWithoutGroup.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithSameGroup1.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithOtherGroup.StringId + } + } + }; + + string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(3); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingUserWithoutGroup.Id); + content1.GroupId.Should().Be(existingGroup.Id); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUserWithOtherGroup.Id); + content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); + content2.AfterGroupId.Should().Be(existingGroup.Id); + + var content3 = messageBroker.SentMessages[2].GetContentAs(); + content3.UserId.Should().Be(existingUserWithSameGroup2.Id); + content3.GroupId.Should().Be(existingGroup.Id); + } + + [Fact] + public async Task Add_users_to_group_sends_messages() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + + DomainUser existingUserWithSameGroup = _fakers.DomainUser.Generate(); + existingUserWithSameGroup.Group = existingGroup; + + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup, existingUserWithOtherGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "domainUsers", + id = existingUserWithoutGroup.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithOtherGroup.StringId + } + } + }; + + string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(2); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingUserWithoutGroup.Id); + content1.GroupId.Should().Be(existingGroup.Id); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUserWithOtherGroup.Id); + content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); + content2.AfterGroupId.Should().Be(existingGroup.Id); + } + + [Fact] + public async Task Remove_users_from_group_sends_messages() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup1.Group = existingGroup; + + DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup2.Group = existingGroup; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.AddRange(existingUserWithSameGroup1, existingUserWithSameGroup2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "domainUsers", + id = existingUserWithSameGroup2.StringId + } + } + }; + + string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(1); + + var content = messageBroker.SentMessages[0].GetContentAs(); + content.UserId.Should().Be(existingUserWithSameGroup2.Id); + content.GroupId.Should().Be(existingGroup.Id); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs new file mode 100644 index 0000000000..7c0fe9c3fb --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs @@ -0,0 +1,543 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.FireAndForgetDelivery +{ + public sealed partial class FireForgetTests + { + [Fact] + public async Task Create_user_sends_messages() + { + // Arrange + string newLoginName = _fakers.DomainUser.Generate().LoginName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + + var requestBody = new + { + data = new + { + type = "domainUsers", + attributes = new + { + loginName = newLoginName, + displayName = newDisplayName + } + } + }; + + const string route = "/domainUsers"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["loginName"].Should().Be(newLoginName); + responseDocument.SingleData.Attributes["displayName"].Should().Be(newDisplayName); + + Guid newUserId = Guid.Parse(responseDocument.SingleData.Id); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(1); + + var content = messageBroker.SentMessages[0].GetContentAs(); + content.UserId.Should().Be(newUserId); + content.UserLoginName.Should().Be(newLoginName); + content.UserDisplayName.Should().Be(newDisplayName); + } + + [Fact] + public async Task Create_user_in_group_sends_messages() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + string newLoginName = _fakers.DomainUser.Generate().LoginName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainUsers", + attributes = new + { + loginName = newLoginName + }, + relationships = new + { + group = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId + } + } + } + } + }; + + const string route = "/domainUsers"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["loginName"].Should().Be(newLoginName); + responseDocument.SingleData.Attributes["displayName"].Should().BeNull(); + + Guid newUserId = Guid.Parse(responseDocument.SingleData.Id); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(2); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(newUserId); + content1.UserLoginName.Should().Be(newLoginName); + content1.UserDisplayName.Should().BeNull(); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(newUserId); + content2.GroupId.Should().Be(existingGroup.Id); + } + + [Fact] + public async Task Update_user_sends_messages() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + + string newLoginName = _fakers.DomainUser.Generate().LoginName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainUsers", + id = existingUser.StringId, + attributes = new + { + loginName = newLoginName, + displayName = newDisplayName + } + } + }; + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(2); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.BeforeUserLoginName.Should().Be(existingUser.LoginName); + content1.AfterUserLoginName.Should().Be(newLoginName); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + content2.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); + content2.AfterUserDisplayName.Should().Be(newDisplayName); + } + + [Fact] + public async Task Update_user_clear_group_sends_messages() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); + + string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainUsers", + id = existingUser.StringId, + attributes = new + { + displayName = newDisplayName + }, + relationships = new + { + group = new + { + data = (object)null + } + } + } + }; + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(2); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); + content1.AfterUserDisplayName.Should().Be(newDisplayName); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + content2.GroupId.Should().Be(existingUser.Group.Id); + } + + [Fact] + public async Task Update_user_add_to_group_sends_messages() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingUser, existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainUsers", + id = existingUser.StringId, + attributes = new + { + displayName = newDisplayName + }, + relationships = new + { + group = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId + } + } + } + } + }; + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(2); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); + content1.AfterUserDisplayName.Should().Be(newDisplayName); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + content2.GroupId.Should().Be(existingGroup.Id); + } + + [Fact] + public async Task Update_user_move_to_group_sends_messages() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); + + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingUser, existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainUsers", + id = existingUser.StringId, + attributes = new + { + displayName = newDisplayName + }, + relationships = new + { + group = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId + } + } + } + } + }; + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(2); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); + content1.AfterUserDisplayName.Should().Be(newDisplayName); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + content2.BeforeGroupId.Should().Be(existingUser.Group.Id); + content2.AfterGroupId.Should().Be(existingGroup.Id); + } + + [Fact] + public async Task Delete_user_sends_messages() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(1); + + var content = messageBroker.SentMessages[0].GetContentAs(); + content.UserId.Should().Be(existingUser.Id); + } + + [Fact] + public async Task Delete_user_in_group_sends_messages() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(2); + + var content1 = messageBroker.SentMessages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.GroupId.Should().Be(existingUser.Group.Id); + + var content2 = messageBroker.SentMessages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + } + + [Fact] + public async Task Clear_group_from_user_sends_messages() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object)null + }; + + string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(1); + + var content = messageBroker.SentMessages[0].GetContentAs(); + content.UserId.Should().Be(existingUser.Id); + content.GroupId.Should().Be(existingUser.Group.Id); + } + + [Fact] + public async Task Assign_group_to_user_sends_messages() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingUser, existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId + } + }; + + string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(1); + + var content = messageBroker.SentMessages[0].GetContentAs(); + content.UserId.Should().Be(existingUser.Id); + content.GroupId.Should().Be(existingGroup.Id); + } + + [Fact] + public async Task Replace_group_for_user_sends_messages() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); + + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingUser, existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId + } + }; + + string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().HaveCount(1); + + var content = messageBroker.SentMessages[0].GetContentAs(); + content.UserId.Should().Be(existingUser.Id); + content.BeforeGroupId.Should().Be(existingUser.Group.Id); + content.AfterGroupId.Should().Be(existingGroup.Id); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs new file mode 100644 index 0000000000..db834ac227 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs @@ -0,0 +1,102 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.FireAndForgetDelivery +{ + public sealed partial class FireForgetTests : IClassFixture, FireForgetDbContext>> + { + private readonly ExampleIntegrationTestContext, FireForgetDbContext> _testContext; + private readonly DomainFakers _fakers = new DomainFakers(); + + public FireForgetTests(ExampleIntegrationTestContext, FireForgetDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); + services.AddResourceDefinition(); + + services.AddSingleton(); + }); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.Reset(); + } + + [Fact] + public async Task Does_not_send_message_on_write_error() + { + // Arrange + string missingUserId = Guid.NewGuid().ToString(); + + string route = "/domainUsers/" + missingUserId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'domainUsers' with ID '{missingUserId}' does not exist."); + + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SentMessages.Should().BeEmpty(); + } + + [Fact] + public async Task Does_not_rollback_on_message_delivery_error() + { + // Arrange + var messageBroker = _testContext.Factory.Services.GetRequiredService(); + messageBroker.SimulateFailure = true; + + DomainUser existingUser = _fakers.DomainUser.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.ServiceUnavailable); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); + error.Title.Should().Be("Message delivery failed."); + error.Detail.Should().BeNull(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + DomainUser user = await dbContext.Users.FirstWithIdOrDefaultAsync(existingUser.Id); + user.Should().BeNull(); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetUserDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetUserDefinition.cs new file mode 100644 index 0000000000..e07bf899e7 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetUserDefinition.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.FireAndForgetDelivery +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public sealed class FireForgetUserDefinition : MessagingUserDefinition + { + private readonly MessageBroker _messageBroker; + private DomainUser _userToDelete; + + public FireForgetUserDefinition(IResourceGraph resourceGraph, FireForgetDbContext dbContext, MessageBroker messageBroker) + : base(resourceGraph, dbContext.Users) + { + _messageBroker = messageBroker; + } + + public override async Task OnWritingAsync(DomainUser user, OperationKind operationKind, CancellationToken cancellationToken) + { + if (operationKind == OperationKind.DeleteResource) + { + _userToDelete = await base.GetUserToDeleteAsync(user.Id, cancellationToken); + } + } + + public override Task OnWriteSucceededAsync(DomainUser user, OperationKind operationKind, CancellationToken cancellationToken) + { + return FinishWriteAsync(user, operationKind, cancellationToken); + } + + protected override Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) + { + return _messageBroker.PostMessageAsync(message, cancellationToken); + } + + protected override Task GetUserToDeleteAsync(Guid userId, CancellationToken cancellationToken) + { + return Task.FromResult(_userToDelete); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/MessageBroker.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/MessageBroker.cs new file mode 100644 index 0000000000..1de7fed1a1 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/MessageBroker.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.FireAndForgetDelivery +{ + public sealed class MessageBroker + { + internal IList SentMessages { get; } = new List(); + + internal bool SimulateFailure { get; set; } + + internal void Reset() + { + SimulateFailure = false; + SentMessages.Clear(); + } + + internal Task PostMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) + { + if (SimulateFailure) + { + throw new JsonApiException(new Error(HttpStatusCode.ServiceUnavailable) + { + Title = "Message delivery failed." + }); + } + + cancellationToken.ThrowIfCancellationRequested(); + + SentMessages.Add(message); + + return Task.CompletedTask; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs new file mode 100644 index 0000000000..78e0d34b90 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs @@ -0,0 +1,14 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class GroupCreatedContent : IMessageContent + { + public int FormatVersion => 1; + + public Guid GroupId { get; set; } + public string GroupName { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs new file mode 100644 index 0000000000..c168671ba6 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs @@ -0,0 +1,13 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class GroupDeletedContent : IMessageContent + { + public int FormatVersion => 1; + + public Guid GroupId { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs new file mode 100644 index 0000000000..0da1eb58ff --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs @@ -0,0 +1,15 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class GroupRenamedContent : IMessageContent + { + public int FormatVersion => 1; + + public Guid GroupId { get; set; } + public string BeforeGroupName { get; set; } + public string AfterGroupName { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/IMessageContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/IMessageContent.cs new file mode 100644 index 0000000000..e95b74e673 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/IMessageContent.cs @@ -0,0 +1,8 @@ +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages +{ + public interface IMessageContent + { + // Increment when content structure changes. + int FormatVersion { get; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/OutgoingMessage.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/OutgoingMessage.cs new file mode 100644 index 0000000000..68837f0d12 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/OutgoingMessage.cs @@ -0,0 +1,33 @@ +using JetBrains.Annotations; +using Newtonsoft.Json; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class OutgoingMessage + { + public long Id { get; set; } + public string Type { get; set; } + public int FormatVersion { get; set; } + public string Content { get; set; } + + public T GetContentAs() + where T : IMessageContent + { + string namespacePrefix = typeof(IMessageContent).Namespace; + var contentType = System.Type.GetType(namespacePrefix + "." + Type, true); + + return (T)JsonConvert.DeserializeObject(Content, contentType); + } + + public static OutgoingMessage CreateFromContent(IMessageContent content) + { + return new OutgoingMessage + { + Type = content.GetType().Name, + FormatVersion = content.FormatVersion, + Content = JsonConvert.SerializeObject(content) + }; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs new file mode 100644 index 0000000000..209f1d4035 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs @@ -0,0 +1,14 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class UserAddedToGroupContent : IMessageContent + { + public int FormatVersion => 1; + + public Guid UserId { get; set; } + public Guid GroupId { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs new file mode 100644 index 0000000000..ebbffa4152 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs @@ -0,0 +1,15 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class UserCreatedContent : IMessageContent + { + public int FormatVersion => 1; + + public Guid UserId { get; set; } + public string UserLoginName { get; set; } + public string UserDisplayName { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs new file mode 100644 index 0000000000..94c77c0b49 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs @@ -0,0 +1,13 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class UserDeletedContent : IMessageContent + { + public int FormatVersion => 1; + + public Guid UserId { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs new file mode 100644 index 0000000000..f461de5807 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs @@ -0,0 +1,15 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class UserDisplayNameChangedContent : IMessageContent + { + public int FormatVersion => 1; + + public Guid UserId { get; set; } + public string BeforeUserDisplayName { get; set; } + public string AfterUserDisplayName { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs new file mode 100644 index 0000000000..b6abe8a478 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs @@ -0,0 +1,15 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class UserLoginNameChangedContent : IMessageContent + { + public int FormatVersion => 1; + + public Guid UserId { get; set; } + public string BeforeUserLoginName { get; set; } + public string AfterUserLoginName { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs new file mode 100644 index 0000000000..1ef56bc316 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs @@ -0,0 +1,15 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class UserMovedToGroupContent : IMessageContent + { + public int FormatVersion => 1; + + public Guid UserId { get; set; } + public Guid BeforeGroupId { get; set; } + public Guid AfterGroupId { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs new file mode 100644 index 0000000000..82cce6824e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs @@ -0,0 +1,14 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class UserRemovedFromGroupContent : IMessageContent + { + public int FormatVersion => 1; + + public Guid UserId { get; set; } + public Guid GroupId { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs new file mode 100644 index 0000000000..9d99fe6793 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices +{ + public abstract class MessagingGroupDefinition : JsonApiResourceDefinition + { + private readonly DbSet _userSet; + private readonly DbSet _groupSet; + private readonly List _pendingMessages = new List(); + + private string _beforeGroupName; + + protected MessagingGroupDefinition(IResourceGraph resourceGraph, DbSet userSet, DbSet groupSet) + : base(resourceGraph) + { + _userSet = userSet; + _groupSet = groupSet; + } + + public override Task OnPrepareWriteAsync(DomainGroup group, OperationKind operationKind, CancellationToken cancellationToken) + { + if (operationKind == OperationKind.CreateResource) + { + group.Id = Guid.NewGuid(); + } + else if (operationKind == OperationKind.UpdateResource) + { + _beforeGroupName = group.Name; + } + + return Task.CompletedTask; + } + + public override async Task OnSetToManyRelationshipAsync(DomainGroup group, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + OperationKind operationKind, CancellationToken cancellationToken) + { + if (hasManyRelationship.Property.Name == nameof(DomainGroup.Users)) + { + HashSet rightUserIds = rightResourceIds.Select(resource => (Guid)resource.GetTypedId()).ToHashSet(); + + List beforeUsers = await _userSet.Include(user => user.Group).Where(user => rightUserIds.Contains(user.Id)) + .ToListAsync(cancellationToken); + + foreach (DomainUser beforeUser in beforeUsers) + { + IMessageContent content = null; + + if (beforeUser.Group == null) + { + content = new UserAddedToGroupContent + { + UserId = beforeUser.Id, + GroupId = group.Id + }; + } + else if (beforeUser.Group != null && beforeUser.Group.Id != group.Id) + { + content = new UserMovedToGroupContent + { + UserId = beforeUser.Id, + BeforeGroupId = beforeUser.Group.Id, + AfterGroupId = group.Id + }; + } + + if (content != null) + { + _pendingMessages.Add(OutgoingMessage.CreateFromContent(content)); + } + } + + if (group.Users != null) + { + foreach (DomainUser userToRemoveFromGroup in group.Users.Where(user => !rightUserIds.Contains(user.Id))) + { + var message = OutgoingMessage.CreateFromContent(new UserRemovedFromGroupContent + { + UserId = userToRemoveFromGroup.Id, + GroupId = group.Id + }); + + _pendingMessages.Add(message); + } + } + } + } + + public override async Task OnAddToRelationshipAsync(Guid groupId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + { + if (hasManyRelationship.Property.Name == nameof(DomainGroup.Users)) + { + HashSet rightUserIds = rightResourceIds.Select(resource => (Guid)resource.GetTypedId()).ToHashSet(); + + List beforeUsers = await _userSet.Include(user => user.Group).Where(user => rightUserIds.Contains(user.Id)) + .ToListAsync(cancellationToken); + + foreach (DomainUser beforeUser in beforeUsers) + { + IMessageContent content = null; + + if (beforeUser.Group == null) + { + content = new UserAddedToGroupContent + { + UserId = beforeUser.Id, + GroupId = groupId + }; + } + else if (beforeUser.Group != null && beforeUser.Group.Id != groupId) + { + content = new UserMovedToGroupContent + { + UserId = beforeUser.Id, + BeforeGroupId = beforeUser.Group.Id, + AfterGroupId = groupId + }; + } + + if (content != null) + { + _pendingMessages.Add(OutgoingMessage.CreateFromContent(content)); + } + } + } + } + + public override Task OnRemoveFromRelationshipAsync(DomainGroup group, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + { + if (hasManyRelationship.Property.Name == nameof(DomainGroup.Users)) + { + HashSet rightUserIds = rightResourceIds.Select(resource => (Guid)resource.GetTypedId()).ToHashSet(); + + foreach (DomainUser userToRemoveFromGroup in group.Users.Where(user => rightUserIds.Contains(user.Id))) + { + var message = OutgoingMessage.CreateFromContent(new UserRemovedFromGroupContent + { + UserId = userToRemoveFromGroup.Id, + GroupId = group.Id + }); + + _pendingMessages.Add(message); + } + } + + return Task.CompletedTask; + } + + protected async Task FinishWriteAsync(DomainGroup group, OperationKind operationKind, CancellationToken cancellationToken) + { + if (operationKind == OperationKind.CreateResource) + { + var message = OutgoingMessage.CreateFromContent(new GroupCreatedContent + { + GroupId = group.Id, + GroupName = group.Name + }); + + await FlushMessageAsync(message, cancellationToken); + } + else if (operationKind == OperationKind.UpdateResource) + { + if (_beforeGroupName != group.Name) + { + var message = OutgoingMessage.CreateFromContent(new GroupRenamedContent + { + GroupId = group.Id, + BeforeGroupName = _beforeGroupName, + AfterGroupName = group.Name + }); + + await FlushMessageAsync(message, cancellationToken); + } + } + else if (operationKind == OperationKind.DeleteResource) + { + DomainGroup groupToDelete = await GetGroupToDeleteAsync(group.Id, cancellationToken); + + if (groupToDelete != null) + { + foreach (DomainUser user in groupToDelete.Users) + { + var removeMessage = OutgoingMessage.CreateFromContent(new UserRemovedFromGroupContent + { + UserId = user.Id, + GroupId = group.Id + }); + + await FlushMessageAsync(removeMessage, cancellationToken); + } + } + + var deleteMessage = OutgoingMessage.CreateFromContent(new GroupDeletedContent + { + GroupId = group.Id + }); + + await FlushMessageAsync(deleteMessage, cancellationToken); + } + + foreach (OutgoingMessage nextMessage in _pendingMessages) + { + await FlushMessageAsync(nextMessage, cancellationToken); + } + } + + protected abstract Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken); + + protected virtual async Task GetGroupToDeleteAsync(Guid groupId, CancellationToken cancellationToken) + { + return await _groupSet.Include(group => group.Users).FirstOrDefaultAsync(group => group.Id == groupId, cancellationToken); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/MessagingUserDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/MessagingUserDefinition.cs new file mode 100644 index 0000000000..5b2dc27b7f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/MessagingUserDefinition.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices +{ + public abstract class MessagingUserDefinition : JsonApiResourceDefinition + { + private readonly DbSet _userSet; + private readonly List _pendingMessages = new List(); + + private string _beforeLoginName; + private string _beforeDisplayName; + + protected MessagingUserDefinition(IResourceGraph resourceGraph, DbSet userSet) + : base(resourceGraph) + { + _userSet = userSet; + } + + public override Task OnPrepareWriteAsync(DomainUser user, OperationKind operationKind, CancellationToken cancellationToken) + { + if (operationKind == OperationKind.CreateResource) + { + user.Id = Guid.NewGuid(); + } + else if (operationKind == OperationKind.UpdateResource) + { + _beforeLoginName = user.LoginName; + _beforeDisplayName = user.DisplayName; + } + + return Task.CompletedTask; + } + + public override Task OnSetToOneRelationshipAsync(DomainUser user, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, + OperationKind operationKind, CancellationToken cancellationToken) + { + if (hasOneRelationship.Property.Name == nameof(DomainUser.Group)) + { + var afterGroupId = (Guid?)rightResourceId?.GetTypedId(); + IMessageContent content = null; + + if (user.Group != null && afterGroupId == null) + { + content = new UserRemovedFromGroupContent + { + UserId = user.Id, + GroupId = user.Group.Id + }; + } + else if (user.Group == null && afterGroupId != null) + { + content = new UserAddedToGroupContent + { + UserId = user.Id, + GroupId = afterGroupId.Value + }; + } + else if (user.Group != null && afterGroupId != null && user.Group.Id != afterGroupId) + { + content = new UserMovedToGroupContent + { + UserId = user.Id, + BeforeGroupId = user.Group.Id, + AfterGroupId = afterGroupId.Value + }; + } + + if (content != null) + { + var message = OutgoingMessage.CreateFromContent(content); + _pendingMessages.Add(message); + } + } + + return Task.FromResult(rightResourceId); + } + + protected async Task FinishWriteAsync(DomainUser user, OperationKind operationKind, CancellationToken cancellationToken) + { + if (operationKind == OperationKind.CreateResource) + { + var message = OutgoingMessage.CreateFromContent(new UserCreatedContent + { + UserId = user.Id, + UserLoginName = user.LoginName, + UserDisplayName = user.DisplayName + }); + + await FlushMessageAsync(message, cancellationToken); + } + else if (operationKind == OperationKind.UpdateResource) + { + if (_beforeLoginName != user.LoginName) + { + var message = OutgoingMessage.CreateFromContent(new UserLoginNameChangedContent + { + UserId = user.Id, + BeforeUserLoginName = _beforeLoginName, + AfterUserLoginName = user.LoginName + }); + + await FlushMessageAsync(message, cancellationToken); + } + + if (_beforeDisplayName != user.DisplayName) + { + var message = OutgoingMessage.CreateFromContent(new UserDisplayNameChangedContent + { + UserId = user.Id, + BeforeUserDisplayName = _beforeDisplayName, + AfterUserDisplayName = user.DisplayName + }); + + await FlushMessageAsync(message, cancellationToken); + } + } + else if (operationKind == OperationKind.DeleteResource) + { + DomainUser userToDelete = await GetUserToDeleteAsync(user.Id, cancellationToken); + + if (userToDelete?.Group != null) + { + var removeMessage = OutgoingMessage.CreateFromContent(new UserRemovedFromGroupContent + { + UserId = user.Id, + GroupId = userToDelete.Group.Id + }); + + await FlushMessageAsync(removeMessage, cancellationToken); + } + + var deleteMessage = OutgoingMessage.CreateFromContent(new UserDeletedContent + { + UserId = user.Id + }); + + await FlushMessageAsync(deleteMessage, cancellationToken); + } + + foreach (OutgoingMessage nextMessage in _pendingMessages) + { + await FlushMessageAsync(nextMessage, cancellationToken); + } + } + + protected abstract Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken); + + protected virtual async Task GetUserToDeleteAsync(Guid userId, CancellationToken cancellationToken) + { + return await _userSet.Include(domainUser => domainUser.Group).FirstOrDefaultAsync(domainUser => domainUser.Id == userId, cancellationToken); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs new file mode 100644 index 0000000000..04edfda455 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.TransactionalOutboxPattern +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class OutboxDbContext : DbContext + { + public DbSet Users { get; set; } + public DbSet Groups { get; set; } + public DbSet OutboxMessages { get; set; } + + public OutboxDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxGroupDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxGroupDefinition.cs new file mode 100644 index 0000000000..52245827bc --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxGroupDefinition.cs @@ -0,0 +1,32 @@ +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.TransactionalOutboxPattern +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public sealed class OutboxGroupDefinition : MessagingGroupDefinition + { + private readonly DbSet _outboxMessageSet; + + public OutboxGroupDefinition(IResourceGraph resourceGraph, OutboxDbContext dbContext) + : base(resourceGraph, dbContext.Users, dbContext.Groups) + { + _outboxMessageSet = dbContext.OutboxMessages; + } + + public override Task OnWritingAsync(DomainGroup group, OperationKind operationKind, CancellationToken cancellationToken) + { + return FinishWriteAsync(group, operationKind, cancellationToken); + } + + protected override async Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) + { + await _outboxMessageSet.AddAsync(message, cancellationToken); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs new file mode 100644 index 0000000000..a3bf1ad6b1 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs @@ -0,0 +1,547 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.TransactionalOutboxPattern +{ + public sealed partial class OutboxTests + { + [Fact] + public async Task Create_group_writes_to_outbox() + { + // Arrange + string newGroupName = _fakers.DomainGroup.Generate().Name; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainGroups", + attributes = new + { + name = newGroupName + } + } + }; + + const string route = "/domainGroups"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["name"].Should().Be(newGroupName); + + Guid newGroupId = Guid.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(1); + + var content = messages[0].GetContentAs(); + content.GroupId.Should().Be(newGroupId); + content.GroupName.Should().Be(newGroupName); + }); + } + + [Fact] + public async Task Create_group_with_users_writes_to_outbox() + { + // Arrange + DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + + string newGroupName = _fakers.DomainGroup.Generate().Name; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithOtherGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainGroups", + attributes = new + { + name = newGroupName + }, + relationships = new + { + users = new + { + data = new[] + { + new + { + type = "domainUsers", + id = existingUserWithoutGroup.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithOtherGroup.StringId + } + } + } + } + } + }; + + const string route = "/domainGroups"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["name"].Should().Be(newGroupName); + + Guid newGroupId = Guid.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(3); + + var content1 = messages[0].GetContentAs(); + content1.GroupId.Should().Be(newGroupId); + content1.GroupName.Should().Be(newGroupName); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUserWithoutGroup.Id); + content2.GroupId.Should().Be(newGroupId); + + var content3 = messages[2].GetContentAs(); + content3.UserId.Should().Be(existingUserWithOtherGroup.Id); + content3.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); + content3.AfterGroupId.Should().Be(newGroupId); + }); + } + + [Fact] + public async Task Update_group_writes_to_outbox() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + string newGroupName = _fakers.DomainGroup.Generate().Name; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId, + attributes = new + { + name = newGroupName + } + } + }; + + string route = "/domainGroups/" + existingGroup.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(1); + + var content = messages[0].GetContentAs(); + content.GroupId.Should().Be(existingGroup.StringId); + content.BeforeGroupName.Should().Be(existingGroup.Name); + content.AfterGroupName.Should().Be(newGroupName); + }); + } + + [Fact] + public async Task Update_group_with_users_writes_to_outbox() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + + DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup1.Group = existingGroup; + + DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup2.Group = existingGroup; + + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup1, existingUserWithSameGroup2, existingUserWithOtherGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId, + relationships = new + { + users = new + { + data = new[] + { + new + { + type = "domainUsers", + id = existingUserWithoutGroup.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithSameGroup1.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithOtherGroup.StringId + } + } + } + } + } + }; + + string route = "/domainGroups/" + existingGroup.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(3); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingUserWithoutGroup.Id); + content1.GroupId.Should().Be(existingGroup.Id); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUserWithOtherGroup.Id); + content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); + content2.AfterGroupId.Should().Be(existingGroup.Id); + + var content3 = messages[2].GetContentAs(); + content3.UserId.Should().Be(existingUserWithSameGroup2.Id); + content3.GroupId.Should().Be(existingGroup.Id); + }); + } + + [Fact] + public async Task Delete_group_writes_to_outbox() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + string route = "/domainGroups/" + existingGroup.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(1); + + var content = messages[0].GetContentAs(); + content.GroupId.Should().Be(existingGroup.StringId); + }); + } + + [Fact] + public async Task Delete_group_with_users_writes_to_outbox() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + existingGroup.Users = _fakers.DomainUser.Generate(1).ToHashSet(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + string route = "/domainGroups/" + existingGroup.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(2); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingGroup.Users.ElementAt(0).Id); + content1.GroupId.Should().Be(existingGroup.StringId); + + var content2 = messages[1].GetContentAs(); + content2.GroupId.Should().Be(existingGroup.StringId); + }); + } + + [Fact] + public async Task Replace_users_in_group_writes_to_outbox() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + + DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup1.Group = existingGroup; + + DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup2.Group = existingGroup; + + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup1, existingUserWithSameGroup2, existingUserWithOtherGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "domainUsers", + id = existingUserWithoutGroup.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithSameGroup1.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithOtherGroup.StringId + } + } + }; + + string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(3); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingUserWithoutGroup.Id); + content1.GroupId.Should().Be(existingGroup.Id); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUserWithOtherGroup.Id); + content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); + content2.AfterGroupId.Should().Be(existingGroup.Id); + + var content3 = messages[2].GetContentAs(); + content3.UserId.Should().Be(existingUserWithSameGroup2.Id); + content3.GroupId.Should().Be(existingGroup.Id); + }); + } + + [Fact] + public async Task Add_users_to_group_writes_to_outbox() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + + DomainUser existingUserWithSameGroup = _fakers.DomainUser.Generate(); + existingUserWithSameGroup.Group = existingGroup; + + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup, existingUserWithOtherGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "domainUsers", + id = existingUserWithoutGroup.StringId + }, + new + { + type = "domainUsers", + id = existingUserWithOtherGroup.StringId + } + } + }; + + string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(2); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingUserWithoutGroup.Id); + content1.GroupId.Should().Be(existingGroup.Id); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUserWithOtherGroup.Id); + content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); + content2.AfterGroupId.Should().Be(existingGroup.Id); + }); + } + + [Fact] + public async Task Remove_users_from_group_writes_to_outbox() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup1.Group = existingGroup; + + DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); + existingUserWithSameGroup2.Group = existingGroup; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.AddRange(existingUserWithSameGroup1, existingUserWithSameGroup2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "domainUsers", + id = existingUserWithSameGroup2.StringId + } + } + }; + + string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(1); + + var content = messages[0].GetContentAs(); + content.UserId.Should().Be(existingUserWithSameGroup2.Id); + content.GroupId.Should().Be(existingGroup.Id); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs new file mode 100644 index 0000000000..599d99d2a1 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs @@ -0,0 +1,593 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.TransactionalOutboxPattern +{ + public sealed partial class OutboxTests + { + [Fact] + public async Task Create_user_writes_to_outbox() + { + // Arrange + string newLoginName = _fakers.DomainUser.Generate().LoginName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainUsers", + attributes = new + { + loginName = newLoginName, + displayName = newDisplayName + } + } + }; + + const string route = "/domainUsers"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["loginName"].Should().Be(newLoginName); + responseDocument.SingleData.Attributes["displayName"].Should().Be(newDisplayName); + + Guid newUserId = Guid.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(1); + + var content = messages[0].GetContentAs(); + content.UserId.Should().Be(newUserId); + content.UserLoginName.Should().Be(newLoginName); + content.UserDisplayName.Should().Be(newDisplayName); + }); + } + + [Fact] + public async Task Create_user_in_group_writes_to_outbox() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + string newLoginName = _fakers.DomainUser.Generate().LoginName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Groups.Add(existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainUsers", + attributes = new + { + loginName = newLoginName + }, + relationships = new + { + group = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId + } + } + } + } + }; + + const string route = "/domainUsers"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["loginName"].Should().Be(newLoginName); + responseDocument.SingleData.Attributes["displayName"].Should().BeNull(); + + Guid newUserId = Guid.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(2); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(newUserId); + content1.UserLoginName.Should().Be(newLoginName); + content1.UserDisplayName.Should().BeNull(); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(newUserId); + content2.GroupId.Should().Be(existingGroup.Id); + }); + } + + [Fact] + public async Task Update_user_writes_to_outbox() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + + string newLoginName = _fakers.DomainUser.Generate().LoginName; + string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainUsers", + id = existingUser.StringId, + attributes = new + { + loginName = newLoginName, + displayName = newDisplayName + } + } + }; + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(2); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.BeforeUserLoginName.Should().Be(existingUser.LoginName); + content1.AfterUserLoginName.Should().Be(newLoginName); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + content2.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); + content2.AfterUserDisplayName.Should().Be(newDisplayName); + }); + } + + [Fact] + public async Task Update_user_clear_group_writes_to_outbox() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); + + string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainUsers", + id = existingUser.StringId, + attributes = new + { + displayName = newDisplayName + }, + relationships = new + { + group = new + { + data = (object)null + } + } + } + }; + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(2); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); + content1.AfterUserDisplayName.Should().Be(newDisplayName); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + content2.GroupId.Should().Be(existingUser.Group.Id); + }); + } + + [Fact] + public async Task Update_user_add_to_group_writes_to_outbox() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingUser, existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainUsers", + id = existingUser.StringId, + attributes = new + { + displayName = newDisplayName + }, + relationships = new + { + group = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId + } + } + } + } + }; + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(2); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); + content1.AfterUserDisplayName.Should().Be(newDisplayName); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + content2.GroupId.Should().Be(existingGroup.Id); + }); + } + + [Fact] + public async Task Update_user_move_to_group_writes_to_outbox() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); + + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + string newDisplayName = _fakers.DomainUser.Generate().DisplayName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingUser, existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainUsers", + id = existingUser.StringId, + attributes = new + { + displayName = newDisplayName + }, + relationships = new + { + group = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId + } + } + } + } + }; + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(2); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); + content1.AfterUserDisplayName.Should().Be(newDisplayName); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + content2.BeforeGroupId.Should().Be(existingUser.Group.Id); + content2.AfterGroupId.Should().Be(existingGroup.Id); + }); + } + + [Fact] + public async Task Delete_user_writes_to_outbox() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(1); + + var content = messages[0].GetContentAs(); + content.UserId.Should().Be(existingUser.Id); + }); + } + + [Fact] + public async Task Delete_user_in_group_writes_to_outbox() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); + + string route = "/domainUsers/" + existingUser.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(2); + + var content1 = messages[0].GetContentAs(); + content1.UserId.Should().Be(existingUser.Id); + content1.GroupId.Should().Be(existingUser.Group.Id); + + var content2 = messages[1].GetContentAs(); + content2.UserId.Should().Be(existingUser.Id); + }); + } + + [Fact] + public async Task Clear_group_from_user_writes_to_outbox() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Users.Add(existingUser); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object)null + }; + + string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(1); + + var content = messages[0].GetContentAs(); + content.UserId.Should().Be(existingUser.Id); + content.GroupId.Should().Be(existingUser.Group.Id); + }); + } + + [Fact] + public async Task Assign_group_to_user_writes_to_outbox() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingUser, existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId + } + }; + + string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(1); + + var content = messages[0].GetContentAs(); + content.UserId.Should().Be(existingUser.Id); + content.GroupId.Should().Be(existingGroup.Id); + }); + } + + [Fact] + public async Task Replace_group_for_user_writes_to_outbox() + { + // Arrange + DomainUser existingUser = _fakers.DomainUser.Generate(); + existingUser.Group = _fakers.DomainGroup.Generate(); + + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingUser, existingGroup); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "domainGroups", + id = existingGroup.StringId + } + }; + + string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().HaveCount(1); + + var content = messages[0].GetContentAs(); + content.UserId.Should().Be(existingUser.Id); + content.BeforeGroupId.Should().Be(existingUser.Group.Id); + content.AfterGroupId.Should().Be(existingGroup.Id); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs new file mode 100644 index 0000000000..a351cefbfe --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.TransactionalOutboxPattern +{ + // Implements the Transactional Outbox Microservices pattern, described at: https://microservices.io/patterns/data/transactional-outbox.html + + public sealed partial class OutboxTests : IClassFixture, OutboxDbContext>> + { + private readonly ExampleIntegrationTestContext, OutboxDbContext> _testContext; + private readonly DomainFakers _fakers = new DomainFakers(); + + public OutboxTests(ExampleIntegrationTestContext, OutboxDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); + services.AddResourceDefinition(); + }); + } + + [Fact] + public async Task Does_not_add_to_outbox_on_write_error() + { + // Arrange + DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + + DomainUser existingUser = _fakers.DomainUser.Generate(); + + string missingUserId = Guid.NewGuid().ToString(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddRange(existingGroup, existingUser); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "domainUsers", + id = existingUser.StringId + }, + new + { + type = "domainUsers", + id = missingUserId + } + } + }; + + string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'domainUsers' with ID '{missingUserId}' in relationship 'users' does not exist."); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); + messages.Should().BeEmpty(); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxUserDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxUserDefinition.cs new file mode 100644 index 0000000000..deae0a9fba --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxUserDefinition.cs @@ -0,0 +1,32 @@ +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.TransactionalOutboxPattern +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public sealed class OutboxUserDefinition : MessagingUserDefinition + { + private readonly DbSet _outboxMessageSet; + + public OutboxUserDefinition(IResourceGraph resourceGraph, OutboxDbContext dbContext) + : base(resourceGraph, dbContext.Users) + { + _outboxMessageSet = dbContext.OutboxMessages; + } + + public override Task OnWritingAsync(DomainUser user, OperationKind operationKind, CancellationToken cancellationToken) + { + return FinishWriteAsync(user, operationKind, cancellationToken); + } + + protected override async Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) + { + await _outboxMessageSet.AddAsync(message, cancellationToken); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/FakeTenantProvider.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/FakeTenantProvider.cs new file mode 100644 index 0000000000..dafbe11176 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/FakeTenantProvider.cs @@ -0,0 +1,17 @@ +using System; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy +{ + internal sealed class FakeTenantProvider : ITenantProvider + { + public Guid TenantId { get; } + + public FakeTenantProvider(Guid tenantId) + { + // A real implementation would be registered at request scope and obtain the tenant ID from the request, for example + // from the incoming authentication token, a custom HTTP header, the route or a query string parameter. + + TenantId = tenantId; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/IHasTenant.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/IHasTenant.cs new file mode 100644 index 0000000000..055f86aef8 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/IHasTenant.cs @@ -0,0 +1,11 @@ +using System; +using JetBrains.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public interface IHasTenant + { + Guid TenantId { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/ITenantProvider.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/ITenantProvider.cs new file mode 100644 index 0000000000..7d0872b20f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/ITenantProvider.cs @@ -0,0 +1,9 @@ +using System; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy +{ + public interface ITenantProvider + { + Guid TenantId { get; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs new file mode 100644 index 0000000000..d45206c35c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs @@ -0,0 +1,36 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +// @formatter:wrap_chained_method_calls chop_always + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class MultiTenancyDbContext : DbContext + { + private readonly ITenantProvider _tenantProvider; + + public DbSet WebShops { get; set; } + public DbSet WebProducts { get; set; } + + public MultiTenancyDbContext(DbContextOptions options, ITenantProvider tenantProvider) + : base(options) + { + _tenantProvider = tenantProvider; + } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasMany(webShop => webShop.Products) + .WithOne(webProduct => webProduct.Shop) + .IsRequired(); + + builder.Entity() + .HasQueryFilter(webShop => webShop.TenantId == _tenantProvider.TenantId); + + builder.Entity() + .HasQueryFilter(webProduct => webProduct.Shop.TenantId == _tenantProvider.TenantId); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyFakers.cs new file mode 100644 index 0000000000..6f1e0cf57b --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyFakers.cs @@ -0,0 +1,26 @@ +using System; +using Bogus; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_always +// @formatter:keep_existing_linebreaks true + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy +{ + internal sealed class MultiTenancyFakers : FakerContainer + { + private readonly Lazy> _lazyWebShopFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(webShop => webShop.Url, faker => faker.Internet.Url())); + + private readonly Lazy> _lazyWebProductFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(webProduct => webProduct.Name, faker => faker.Commerce.ProductName()) + .RuleFor(webProduct => webProduct.Price, faker => faker.Finance.Amount())); + + public Faker WebShop => _lazyWebShopFaker.Value; + public Faker WebProduct => _lazyWebProductFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs new file mode 100644 index 0000000000..39db753277 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs @@ -0,0 +1,989 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy +{ + public sealed class MultiTenancyTests : IClassFixture, MultiTenancyDbContext>> + { + private static readonly Guid ThisTenantId = Guid.NewGuid(); + private static readonly Guid OtherTenantId = Guid.NewGuid(); + + private readonly ExampleIntegrationTestContext, MultiTenancyDbContext> _testContext; + private readonly MultiTenancyFakers _fakers = new MultiTenancyFakers(); + + public MultiTenancyTests(ExampleIntegrationTestContext, MultiTenancyDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServicesBeforeStartup(services => + { + services.AddSingleton(new FakeTenantProvider(ThisTenantId)); + }); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceService>(); + services.AddResourceService>(); + }); + } + + [Fact] + public async Task Get_primary_resources_hides_other_tenants() + { + // Arrange + List shops = _fakers.WebShop.Generate(2); + shops[0].TenantId = OtherTenantId; + shops[1].TenantId = ThisTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.WebShops.AddRange(shops); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/webShops"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(shops[1].StringId); + } + + [Fact] + public async Task Filter_on_primary_resources_hides_other_tenants() + { + // Arrange + List shops = _fakers.WebShop.Generate(2); + shops[0].TenantId = OtherTenantId; + shops[0].Products = _fakers.WebProduct.Generate(1); + + shops[1].TenantId = ThisTenantId; + shops[1].Products = _fakers.WebProduct.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.WebShops.AddRange(shops); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/webShops?filter=has(products)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(shops[1].StringId); + } + + [Fact] + public async Task Get_primary_resources_with_include_hides_other_tenants() + { + // Arrange + List shops = _fakers.WebShop.Generate(2); + shops[0].TenantId = OtherTenantId; + shops[0].Products = _fakers.WebProduct.Generate(1); + + shops[1].TenantId = ThisTenantId; + shops[1].Products = _fakers.WebProduct.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.WebShops.AddRange(shops); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/webShops?include=products"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Type.Should().Be("webShops"); + responseDocument.ManyData[0].Id.Should().Be(shops[1].StringId); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("webProducts"); + responseDocument.Included[0].Id.Should().Be(shops[1].Products[0].StringId); + } + + [Fact] + public async Task Cannot_get_primary_resource_by_ID_from_other_tenant() + { + // Arrange + WebShop shop = _fakers.WebShop.Generate(); + shop.TenantId = OtherTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebShops.Add(shop); + await dbContext.SaveChangesAsync(); + }); + + string route = "/webShops/" + shop.StringId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'webShops' with ID '{shop.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_get_secondary_resources_from_other_parent_tenant() + { + // Arrange + WebShop shop = _fakers.WebShop.Generate(); + shop.TenantId = OtherTenantId; + shop.Products = _fakers.WebProduct.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebShops.Add(shop); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/webShops/{shop.StringId}/products"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'webShops' with ID '{shop.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_get_secondary_resource_from_other_parent_tenant() + { + // Arrange + WebProduct product = _fakers.WebProduct.Generate(); + product.Shop = _fakers.WebShop.Generate(); + product.Shop.TenantId = OtherTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebProducts.Add(product); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/webProducts/{product.StringId}/shop"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'webProducts' with ID '{product.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_get_HasMany_relationship_for_other_parent_tenant() + { + // Arrange + WebShop shop = _fakers.WebShop.Generate(); + shop.TenantId = OtherTenantId; + shop.Products = _fakers.WebProduct.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebShops.Add(shop); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/webShops/{shop.StringId}/relationships/products"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'webShops' with ID '{shop.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_get_HasOne_relationship_for_other_parent_tenant() + { + // Arrange + WebProduct product = _fakers.WebProduct.Generate(); + product.Shop = _fakers.WebShop.Generate(); + product.Shop.TenantId = OtherTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebProducts.Add(product); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/webProducts/{product.StringId}/relationships/shop"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'webProducts' with ID '{product.StringId}' does not exist."); + } + + [Fact] + public async Task Can_create_resource() + { + // Arrange + string newShopUrl = _fakers.WebShop.Generate().Url; + + var requestBody = new + { + data = new + { + type = "webShops", + attributes = new + { + url = newShopUrl + } + } + }; + + const string route = "/webShops"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Attributes["url"].Should().Be(newShopUrl); + responseDocument.SingleData.Relationships.Should().NotBeNull(); + + int newShopId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + WebShop shopInDatabase = await dbContext.WebShops.FirstWithIdAsync(newShopId); + + shopInDatabase.Url.Should().Be(newShopUrl); + shopInDatabase.TenantId.Should().Be(ThisTenantId); + }); + } + + [Fact] + public async Task Cannot_create_resource_with_HasMany_relationship_to_other_tenant() + { + // Arrange + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = OtherTenantId; + + string newShopUrl = _fakers.WebShop.Generate().Url; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebProducts.Add(existingProduct); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "webShops", + attributes = new + { + url = newShopUrl + }, + relationships = new + { + products = new + { + data = new[] + { + new + { + type = "webProducts", + id = existingProduct.StringId + } + } + } + } + } + }; + + const string route = "/webShops"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'webProducts' with ID '{existingProduct.StringId}' in relationship 'products' does not exist."); + } + + [Fact] + public async Task Cannot_create_resource_with_HasOne_relationship_to_other_tenant() + { + // Arrange + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = OtherTenantId; + + string newProductName = _fakers.WebProduct.Generate().Name; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebShops.Add(existingShop); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "webProducts", + attributes = new + { + name = newProductName + }, + relationships = new + { + shop = new + { + data = new + { + type = "webShops", + id = existingShop.StringId + } + } + } + } + }; + + const string route = "/webProducts"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'webShops' with ID '{existingShop.StringId}' in relationship 'shop' does not exist."); + } + + [Fact] + public async Task Can_update_resource() + { + // Arrange + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = ThisTenantId; + + string newProductName = _fakers.WebProduct.Generate().Name; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebProducts.Add(existingProduct); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "webProducts", + id = existingProduct.StringId, + attributes = new + { + name = newProductName + } + } + }; + + string route = "/webProducts/" + existingProduct.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + WebProduct productInDatabase = await dbContext.WebProducts.FirstWithIdAsync(existingProduct.Id); + + productInDatabase.Name.Should().Be(newProductName); + productInDatabase.Price.Should().Be(existingProduct.Price); + }); + } + + [Fact] + public async Task Cannot_update_resource_from_other_tenant() + { + // Arrange + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = OtherTenantId; + + string newProductName = _fakers.WebProduct.Generate().Name; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebProducts.Add(existingProduct); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "webProducts", + id = existingProduct.StringId, + attributes = new + { + name = newProductName + } + } + }; + + string route = "/webProducts/" + existingProduct.StringId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'webProducts' with ID '{existingProduct.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_update_resource_with_HasMany_relationship_to_other_tenant() + { + // Arrange + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = ThisTenantId; + + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = OtherTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingShop, existingProduct); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "webShops", + id = existingShop.StringId, + relationships = new + { + products = new + { + data = new[] + { + new + { + type = "webProducts", + id = existingProduct.StringId + } + } + } + } + } + }; + + string route = "/webShops/" + existingShop.StringId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'webProducts' with ID '{existingProduct.StringId}' in relationship 'products' does not exist."); + } + + [Fact] + public async Task Cannot_update_resource_with_HasOne_relationship_to_other_tenant() + { + // Arrange + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = ThisTenantId; + + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = OtherTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingProduct, existingShop); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "webProducts", + id = existingProduct.StringId, + relationships = new + { + shop = new + { + data = new + { + type = "webShops", + id = existingShop.StringId + } + } + } + } + }; + + string route = "/webProducts/" + existingProduct.StringId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'webShops' with ID '{existingShop.StringId}' in relationship 'shop' does not exist."); + } + + [Fact] + public async Task Cannot_update_HasMany_relationship_for_other_parent_tenant() + { + // Arrange + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = OtherTenantId; + existingShop.Products = _fakers.WebProduct.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebShops.Add(existingShop); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new object[0] + }; + + string route = $"/webShops/{existingShop.StringId}/relationships/products"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'webShops' with ID '{existingShop.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_update_HasMany_relationship_to_other_tenant() + { + // Arrange + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = ThisTenantId; + + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = OtherTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingShop, existingProduct); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "webProducts", + id = existingProduct.StringId + } + } + }; + + string route = $"/webShops/{existingShop.StringId}/relationships/products"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'webProducts' with ID '{existingProduct.StringId}' in relationship 'products' does not exist."); + } + + [Fact] + public async Task Cannot_update_HasOne_relationship_for_other_parent_tenant() + { + // Arrange + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = OtherTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebProducts.Add(existingProduct); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object)null + }; + + string route = $"/webProducts/{existingProduct.StringId}/relationships/shop"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'webProducts' with ID '{existingProduct.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_update_HasOne_relationship_to_other_tenant() + { + // Arrange + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = ThisTenantId; + + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = OtherTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingProduct, existingShop); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "webShops", + id = existingShop.StringId + } + }; + + string route = $"/webProducts/{existingProduct.StringId}/relationships/shop"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'webShops' with ID '{existingShop.StringId}' in relationship 'shop' does not exist."); + } + + [Fact] + public async Task Cannot_add_to_ToMany_relationship_for_other_parent_tenant() + { + // Arrange + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = OtherTenantId; + + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = ThisTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingShop, existingProduct); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "webProducts", + id = existingProduct.StringId + } + } + }; + + string route = $"/webShops/{existingShop.StringId}/relationships/products"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'webShops' with ID '{existingShop.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_add_to_ToMany_relationship_with_other_tenant() + { + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = ThisTenantId; + + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = OtherTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingShop, existingProduct); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "webProducts", + id = existingProduct.StringId + } + } + }; + + string route = $"/webShops/{existingShop.StringId}/relationships/products"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'webProducts' with ID '{existingProduct.StringId}' in relationship 'products' does not exist."); + } + + [Fact] + public async Task Cannot_remove_from_ToMany_relationship_for_other_parent_tenant() + { + // Arrange + WebShop existingShop = _fakers.WebShop.Generate(); + existingShop.TenantId = OtherTenantId; + existingShop.Products = _fakers.WebProduct.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebShops.Add(existingShop); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "webProducts", + id = existingShop.Products[0].StringId + } + } + }; + + string route = $"/webShops/{existingShop.StringId}/relationships/products"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'webShops' with ID '{existingShop.StringId}' does not exist."); + } + + [Fact] + public async Task Can_delete_resource() + { + // Arrange + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = ThisTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebProducts.Add(existingProduct); + await dbContext.SaveChangesAsync(); + }); + + string route = "/webProducts/" + existingProduct.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + WebProduct productInDatabase = await dbContext.WebProducts.FirstWithIdOrDefaultAsync(existingProduct.Id); + + productInDatabase.Should().BeNull(); + }); + } + + [Fact] + public async Task Cannot_delete_resource_from_other_tenant() + { + // Arrange + WebProduct existingProduct = _fakers.WebProduct.Generate(); + existingProduct.Shop = _fakers.WebShop.Generate(); + existingProduct.Shop.TenantId = OtherTenantId; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.WebProducts.Add(existingProduct); + await dbContext.SaveChangesAsync(); + }); + + string route = "/webProducts/" + existingProduct.StringId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'webProducts' with ID '{existingProduct.StringId}' does not exist."); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs new file mode 100644 index 0000000000..68a8d78c1f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +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.MultiTenancy +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public class MultiTenantResourceService : JsonApiResourceService + where TResource : class, IIdentifiable + { + private readonly ITenantProvider _tenantProvider; + + private static bool ResourceHasTenant => typeof(IHasTenant).IsAssignableFrom(typeof(TResource)); + + public MultiTenantResourceService(ITenantProvider tenantProvider, 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) + { + _tenantProvider = tenantProvider; + } + + protected override async Task InitializeResourceAsync(TResource resourceForDatabase, CancellationToken cancellationToken) + { + await base.InitializeResourceAsync(resourceForDatabase, cancellationToken); + + if (ResourceHasTenant) + { + Guid tenantId = _tenantProvider.TenantId; + + var resourceWithTenant = (IHasTenant)resourceForDatabase; + resourceWithTenant.TenantId = tenantId; + } + } + + // To optimize performance, the default resource service does not always fetch all resources on write operations. + // We do that here, to assure everything belongs to the active tenant. On mismatch, a 404 error is thrown. + + public override async Task CreateAsync(TResource resource, CancellationToken cancellationToken) + { + await AssertResourcesToAssignInRelationshipsExistAsync(resource, cancellationToken); + + return await base.CreateAsync(resource, cancellationToken); + } + + public override async Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) + { + await AssertResourcesToAssignInRelationshipsExistAsync(resource, cancellationToken); + + return await base.UpdateAsync(id, resource, cancellationToken); + } + + public override async Task SetRelationshipAsync(TId primaryId, string relationshipName, object secondaryResourceIds, + CancellationToken cancellationToken) + { + await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken); + + await base.SetRelationshipAsync(primaryId, relationshipName, secondaryResourceIds, cancellationToken); + } + + public override async Task AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds, + CancellationToken cancellationToken) + { + _ = await GetPrimaryResourceByIdAsync(primaryId, TopFieldSelection.OnlyIdAttribute, cancellationToken); + await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken); + + await base.AddToToManyRelationshipAsync(primaryId, relationshipName, secondaryResourceIds, cancellationToken); + } + + public override async Task DeleteAsync(TId id, CancellationToken cancellationToken) + { + _ = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute, cancellationToken); + + await base.DeleteAsync(id, cancellationToken); + } + } + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public class MultiTenantResourceService : MultiTenantResourceService, IResourceService + where TResource : class, IIdentifiable + { + public MultiTenantResourceService(ITenantProvider tenantProvider, IResourceRepositoryAccessor repositoryAccessor, + IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, + IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceHookExecutorFacade hookExecutor) + : base(tenantProvider, repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, + hookExecutor) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebProduct.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebProduct.cs new file mode 100644 index 0000000000..afb75e8ceb --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebProduct.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class WebProduct : Identifiable + { + [Attr] + public string Name { get; set; } + + [Attr] + public decimal Price { get; set; } + + [HasOne] + public WebShop Shop { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebProductsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebProductsController.cs new file mode 100644 index 0000000000..fd9f6f0adc --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebProductsController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy +{ + public sealed class WebProductsController : JsonApiController + { + public WebProductsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebShop.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebShop.cs new file mode 100644 index 0000000000..8620924234 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebShop.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class WebShop : Identifiable, IHasTenant + { + [Attr] + public string Url { get; set; } + + public Guid TenantId { get; set; } + + [HasMany] + public IList Products { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebShopsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebShopsController.cs new file mode 100644 index 0000000000..03f9ac1adc --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebShopsController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy +{ + public sealed class WebShopsController : JsonApiController + { + public WebShopsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/CallableDbContext.cs similarity index 95% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableDbContext.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/CallableDbContext.cs index 4a137f12a4..3bac89e480 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/CallableDbContext.cs @@ -1,7 +1,7 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Reading { [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class CallableDbContext : DbContext diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResource.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/CallableResource.cs similarity index 98% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResource.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/CallableResource.cs index 73820a9364..c54bd08317 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResource.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/CallableResource.cs @@ -4,7 +4,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Reading { [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class CallableResource : Identifiable diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/CallableResourceDefinition.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/CallableResourceDefinition.cs index 539fb013ec..4f00b100b5 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourceDefinition.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/CallableResourceDefinition.cs @@ -12,7 +12,7 @@ using JsonApiDotNetCore.Serialization.Objects; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Reading { [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class CallableResourceDefinition : JsonApiResourceDefinition diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourcesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/CallableResourcesController.cs similarity index 89% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourcesController.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/CallableResourcesController.cs index c59db0194b..82707df6dc 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/CallableResourcesController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/CallableResourcesController.cs @@ -1,9 +1,9 @@ -using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Reading { public sealed class CallableResourcesController : JsonApiController { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/IUserRolesService.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/IUserRolesService.cs similarity index 88% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/IUserRolesService.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/IUserRolesService.cs index 8c0bfb5406..ceb2e170ab 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/IUserRolesService.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/IUserRolesService.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Reading { public interface IUserRolesService { diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionQueryCallbackTests.cs similarity index 99% rename from test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs rename to test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionQueryCallbackTests.cs index 204154325a..0f7e8e157a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/ResourceDefinitionQueryCallbackTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Reading/ResourceDefinitionQueryCallbackTests.cs @@ -5,14 +5,13 @@ using FluentAssertions; using FluentAssertions.Extensions; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExampleTests.Startups; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Reading { public sealed class ResourceDefinitionQueryCallbackTests : IClassFixture, CallableDbContext>> @@ -27,7 +26,7 @@ public ResourceDefinitionQueryCallbackTests(ExampleIntegrationTestContext { - services.AddScoped, CallableResourceDefinition>(); + services.AddResourceDefinition(); services.AddSingleton(); }); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/AesEncryptionService.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/AesEncryptionService.cs new file mode 100644 index 0000000000..c772b5c98f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/AesEncryptionService.cs @@ -0,0 +1,50 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Serialization +{ + public sealed class AesEncryptionService : IEncryptionService + { + private static readonly byte[] CryptoKey = Encoding.UTF8.GetBytes("Secret!".PadRight(32, '-')); + + public string Encrypt(string value) + { + using SymmetricAlgorithm cipher = CreateCipher(); + + using ICryptoTransform transform = cipher.CreateEncryptor(); + byte[] plaintext = Encoding.UTF8.GetBytes(value); + byte[] cipherText = transform.TransformFinalBlock(plaintext, 0, plaintext.Length); + + byte[] buffer = new byte[cipher.IV.Length + cipherText.Length]; + Buffer.BlockCopy(cipher.IV, 0, buffer, 0, cipher.IV.Length); + Buffer.BlockCopy(cipherText, 0, buffer, cipher.IV.Length, cipherText.Length); + + return Convert.ToBase64String(buffer); + } + + public string Decrypt(string value) + { + byte[] buffer = Convert.FromBase64String(value); + + using SymmetricAlgorithm cipher = CreateCipher(); + + byte[] initVector = new byte[cipher.IV.Length]; + Buffer.BlockCopy(buffer, 0, initVector, 0, initVector.Length); + cipher.IV = initVector; + + using ICryptoTransform transform = cipher.CreateDecryptor(); + byte[] plainBytes = transform.TransformFinalBlock(buffer, initVector.Length, buffer.Length - initVector.Length); + + return Encoding.UTF8.GetString(plainBytes); + } + + private static SymmetricAlgorithm CreateCipher() + { + var cipher = Aes.Create(); + cipher.Key = CryptoKey; + + return cipher; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/IEncryptionService.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/IEncryptionService.cs new file mode 100644 index 0000000000..2b940ef991 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/IEncryptionService.cs @@ -0,0 +1,9 @@ +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Serialization +{ + public interface IEncryptionService + { + string Encrypt(string value); + + string Decrypt(string value); + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/Scholarship.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/Scholarship.cs new file mode 100644 index 0000000000..397c8df3ec --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/Scholarship.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Serialization +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class Scholarship : Identifiable + { + [Attr] + public string ProgramName { get; set; } + + [Attr] + public decimal Amount { get; set; } + + [HasMany] + public IList Participants { get; set; } + + [HasOne] + public Student PrimaryContact { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/ScholarshipsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/ScholarshipsController.cs new file mode 100644 index 0000000000..0a692758ae --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/ScholarshipsController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Serialization +{ + public sealed class ScholarshipsController : JsonApiController + { + public ScholarshipsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationDbContext.cs new file mode 100644 index 0000000000..86377b229c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationDbContext.cs @@ -0,0 +1,26 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +// @formatter:wrap_chained_method_calls chop_always + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Serialization +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class SerializationDbContext : DbContext + { + public DbSet Students { get; set; } + public DbSet Scholarships { get; set; } + + public SerializationDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasMany(scholarship => scholarship.Participants) + .WithOne(student => student.Scholarship); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationFakers.cs new file mode 100644 index 0000000000..40308802b9 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationFakers.cs @@ -0,0 +1,28 @@ +using System; +using Bogus; +using Bogus.Extensions.UnitedStates; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_always +// @formatter:keep_existing_linebreaks true + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Serialization +{ + internal sealed class SerializationFakers : FakerContainer + { + private readonly Lazy> _lazyStudentFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(student => student.Name, faker => faker.Person.FullName) + .RuleFor(student => student.SocialSecurityNumber, faker => faker.Person.Ssn())); + + private readonly Lazy> _lazyScholarshipFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(scholarship => scholarship.ProgramName, faker => faker.Commerce.Department()) + .RuleFor(scholarship => scholarship.Amount, faker => faker.Finance.Amount())); + + public Faker Student => _lazyStudentFaker.Value; + public Faker Scholarship => _lazyScholarshipFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationHitCounter.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationHitCounter.cs new file mode 100644 index 0000000000..27e3018c11 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationHitCounter.cs @@ -0,0 +1,24 @@ +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Serialization +{ + public sealed class SerializationHitCounter + { + internal int DeserializeCount { get; private set; } + internal int SerializeCount { get; private set; } + + internal void Reset() + { + DeserializeCount = 0; + SerializeCount = 0; + } + + internal void IncrementDeserializeCount() + { + DeserializeCount++; + } + + internal void IncrementSerializeCount() + { + SerializeCount++; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationTests.cs new file mode 100644 index 0000000000..373867521f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/SerializationTests.cs @@ -0,0 +1,735 @@ +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Serialization +{ + public sealed class SerializationTests : IClassFixture, SerializationDbContext>> + { + private readonly ExampleIntegrationTestContext, SerializationDbContext> _testContext; + private readonly SerializationFakers _fakers = new SerializationFakers(); + + public SerializationTests(ExampleIntegrationTestContext, SerializationDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddResourceDefinition(); + + services.AddSingleton(); + services.AddSingleton(); + + services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); + }); + + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + hitCounter.Reset(); + } + + [Fact] + public async Task Encrypts_on_get_primary_resources() + { + // Arrange + var encryptionService = _testContext.Factory.Services.GetRequiredService(); + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + List students = _fakers.Student.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Students.AddRange(students); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/students"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + + string socialSecurityNumber1 = encryptionService.Decrypt((string)responseDocument.ManyData[0].Attributes["socialSecurityNumber"]); + socialSecurityNumber1.Should().Be(students[0].SocialSecurityNumber); + + string socialSecurityNumber2 = encryptionService.Decrypt((string)responseDocument.ManyData[1].Attributes["socialSecurityNumber"]); + socialSecurityNumber2.Should().Be(students[1].SocialSecurityNumber); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(2); + } + + [Fact] + public async Task Encrypts_on_get_primary_resources_with_ToMany_include() + { + // Arrange + var encryptionService = _testContext.Factory.Services.GetRequiredService(); + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + List scholarships = _fakers.Scholarship.Generate(2); + scholarships[0].Participants = _fakers.Student.Generate(2); + scholarships[1].Participants = _fakers.Student.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Scholarships.AddRange(scholarships); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/scholarships?include=participants"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + + responseDocument.Included.Should().HaveCount(4); + + string socialSecurityNumber1 = encryptionService.Decrypt((string)responseDocument.Included[0].Attributes["socialSecurityNumber"]); + socialSecurityNumber1.Should().Be(scholarships[0].Participants[0].SocialSecurityNumber); + + string socialSecurityNumber2 = encryptionService.Decrypt((string)responseDocument.Included[1].Attributes["socialSecurityNumber"]); + socialSecurityNumber2.Should().Be(scholarships[0].Participants[1].SocialSecurityNumber); + + string socialSecurityNumber3 = encryptionService.Decrypt((string)responseDocument.Included[2].Attributes["socialSecurityNumber"]); + socialSecurityNumber3.Should().Be(scholarships[1].Participants[0].SocialSecurityNumber); + + string socialSecurityNumber4 = encryptionService.Decrypt((string)responseDocument.Included[3].Attributes["socialSecurityNumber"]); + socialSecurityNumber4.Should().Be(scholarships[1].Participants[1].SocialSecurityNumber); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(4); + } + + [Fact] + public async Task Encrypts_on_get_primary_resource_by_ID() + { + // Arrange + var encryptionService = _testContext.Factory.Services.GetRequiredService(); + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Student student = _fakers.Student.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Students.Add(student); + await dbContext.SaveChangesAsync(); + }); + + string route = "/students/" + student.StringId; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + string socialSecurityNumber = encryptionService.Decrypt((string)responseDocument.SingleData.Attributes["socialSecurityNumber"]); + socialSecurityNumber.Should().Be(student.SocialSecurityNumber); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(1); + } + + [Fact] + public async Task Encrypts_on_get_secondary_resources() + { + // Arrange + var encryptionService = _testContext.Factory.Services.GetRequiredService(); + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Scholarship scholarship = _fakers.Scholarship.Generate(); + scholarship.Participants = _fakers.Student.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Scholarships.Add(scholarship); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/scholarships/{scholarship.StringId}/participants"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + + string socialSecurityNumber1 = encryptionService.Decrypt((string)responseDocument.ManyData[0].Attributes["socialSecurityNumber"]); + socialSecurityNumber1.Should().Be(scholarship.Participants[0].SocialSecurityNumber); + + string socialSecurityNumber2 = encryptionService.Decrypt((string)responseDocument.ManyData[1].Attributes["socialSecurityNumber"]); + socialSecurityNumber2.Should().Be(scholarship.Participants[1].SocialSecurityNumber); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(2); + } + + [Fact] + public async Task Encrypts_on_get_secondary_resource() + { + // Arrange + var encryptionService = _testContext.Factory.Services.GetRequiredService(); + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Scholarship scholarship = _fakers.Scholarship.Generate(); + scholarship.PrimaryContact = _fakers.Student.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Scholarships.Add(scholarship); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/scholarships/{scholarship.StringId}/primaryContact"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + string socialSecurityNumber = encryptionService.Decrypt((string)responseDocument.SingleData.Attributes["socialSecurityNumber"]); + socialSecurityNumber.Should().Be(scholarship.PrimaryContact.SocialSecurityNumber); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(1); + } + + [Fact] + public async Task Encrypts_on_get_secondary_resource_with_ToOne_include() + { + // Arrange + var encryptionService = _testContext.Factory.Services.GetRequiredService(); + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Scholarship scholarship = _fakers.Scholarship.Generate(); + scholarship.PrimaryContact = _fakers.Student.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Scholarships.Add(scholarship); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/scholarships/{scholarship.StringId}?include=primaryContact"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + responseDocument.Included.Should().HaveCount(1); + + string socialSecurityNumber = encryptionService.Decrypt((string)responseDocument.Included[0].Attributes["socialSecurityNumber"]); + socialSecurityNumber.Should().Be(scholarship.PrimaryContact.SocialSecurityNumber); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(1); + } + + [Fact] + public async Task Decrypts_on_create_resource() + { + // Arrange + var encryptionService = _testContext.Factory.Services.GetRequiredService(); + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + string newName = _fakers.Student.Generate().Name; + string newSocialSecurityNumber = _fakers.Student.Generate().SocialSecurityNumber; + + var requestBody = new + { + data = new + { + type = "students", + attributes = new + { + name = newName, + socialSecurityNumber = encryptionService.Encrypt(newSocialSecurityNumber) + } + } + }; + + const string route = "/students"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + + string socialSecurityNumber = encryptionService.Decrypt((string)responseDocument.SingleData.Attributes["socialSecurityNumber"]); + socialSecurityNumber.Should().Be(newSocialSecurityNumber); + + int newStudentId = int.Parse(responseDocument.SingleData.Id); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Student studentInDatabase = await dbContext.Students.FirstWithIdAsync(newStudentId); + + studentInDatabase.SocialSecurityNumber.Should().Be(newSocialSecurityNumber); + }); + + hitCounter.DeserializeCount.Should().Be(1); + hitCounter.SerializeCount.Should().Be(1); + } + + [Fact] + public async Task Encrypts_on_create_resource_with_included_ToOne_relationship() + { + // Arrange + var encryptionService = _testContext.Factory.Services.GetRequiredService(); + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Student existingStudent = _fakers.Student.Generate(); + + string newProgramName = _fakers.Scholarship.Generate().ProgramName; + decimal newAmount = _fakers.Scholarship.Generate().Amount; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Students.Add(existingStudent); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "scholarships", + attributes = new + { + programName = newProgramName, + amount = newAmount + }, + relationships = new + { + primaryContact = new + { + data = new + { + type = "students", + id = existingStudent.StringId + } + } + } + } + }; + + const string route = "/scholarships?include=primaryContact"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.SingleData.Should().NotBeNull(); + + responseDocument.Included.Should().HaveCount(1); + + string socialSecurityNumber = encryptionService.Decrypt((string)responseDocument.Included[0].Attributes["socialSecurityNumber"]); + socialSecurityNumber.Should().Be(existingStudent.SocialSecurityNumber); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(1); + } + + [Fact] + public async Task Decrypts_on_update_resource() + { + // Arrange + var encryptionService = _testContext.Factory.Services.GetRequiredService(); + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Student existingStudent = _fakers.Student.Generate(); + + string newSocialSecurityNumber = _fakers.Student.Generate().SocialSecurityNumber; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Students.Add(existingStudent); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "students", + id = existingStudent.StringId, + attributes = new + { + socialSecurityNumber = encryptionService.Encrypt(newSocialSecurityNumber) + } + } + }; + + string route = "/students/" + existingStudent.StringId; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + string socialSecurityNumber = encryptionService.Decrypt((string)responseDocument.SingleData.Attributes["socialSecurityNumber"]); + socialSecurityNumber.Should().Be(newSocialSecurityNumber); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Student studentInDatabase = await dbContext.Students.FirstWithIdAsync(existingStudent.Id); + + studentInDatabase.SocialSecurityNumber.Should().Be(newSocialSecurityNumber); + }); + + hitCounter.DeserializeCount.Should().Be(1); + hitCounter.SerializeCount.Should().Be(1); + } + + [Fact] + public async Task Encrypts_on_update_resource_with_included_ToMany_relationship() + { + // Arrange + var encryptionService = _testContext.Factory.Services.GetRequiredService(); + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Scholarship existingScholarship = _fakers.Scholarship.Generate(); + existingScholarship.Participants = _fakers.Student.Generate(3); + + decimal newAmount = _fakers.Scholarship.Generate().Amount; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Scholarships.Add(existingScholarship); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "scholarships", + id = existingScholarship.StringId, + attributes = new + { + amount = newAmount + }, + relationships = new + { + participants = new + { + data = new[] + { + new + { + type = "students", + id = existingScholarship.Participants[0].StringId + }, + new + { + type = "students", + id = existingScholarship.Participants[2].StringId + } + } + } + } + } + }; + + string route = $"/scholarships/{existingScholarship.StringId}?include=participants"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + + responseDocument.Included.Should().HaveCount(2); + + string socialSecurityNumber1 = encryptionService.Decrypt((string)responseDocument.Included[0].Attributes["socialSecurityNumber"]); + socialSecurityNumber1.Should().Be(existingScholarship.Participants[0].SocialSecurityNumber); + + string socialSecurityNumber2 = encryptionService.Decrypt((string)responseDocument.Included[1].Attributes["socialSecurityNumber"]); + socialSecurityNumber2.Should().Be(existingScholarship.Participants[2].SocialSecurityNumber); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(2); + } + + [Fact] + public async Task Skips_on_get_ToOne_relationship() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Scholarship scholarship = _fakers.Scholarship.Generate(); + scholarship.PrimaryContact = _fakers.Student.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Scholarships.Add(scholarship); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/scholarships/{scholarship.StringId}/relationships/primaryContact"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Id.Should().Be(scholarship.PrimaryContact.StringId); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(0); + } + + [Fact] + public async Task Skips_on_get_ToMany_relationship() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Scholarship scholarship = _fakers.Scholarship.Generate(); + scholarship.Participants = _fakers.Student.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Scholarships.Add(scholarship); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/scholarships/{scholarship.StringId}/relationships/participants"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(2); + responseDocument.ManyData[0].Id.Should().Be(scholarship.Participants[0].StringId); + responseDocument.ManyData[1].Id.Should().Be(scholarship.Participants[1].StringId); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(0); + } + + [Fact] + public async Task Skips_on_update_ToOne_relationship() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Scholarship existingScholarship = _fakers.Scholarship.Generate(); + Student existingStudent = _fakers.Student.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingScholarship, existingStudent); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "students", + id = existingStudent.StringId + } + }; + + string route = $"/scholarships/{existingScholarship.StringId}/relationships/primaryContact"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(0); + } + + [Fact] + public async Task Skips_on_set_ToMany_relationship() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Scholarship existingScholarship = _fakers.Scholarship.Generate(); + List existingStudents = _fakers.Student.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Scholarships.Add(existingScholarship); + dbContext.Students.AddRange(existingStudents); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "students", + id = existingStudents[0].StringId + }, + new + { + type = "students", + id = existingStudents[1].StringId + } + } + }; + + string route = $"/scholarships/{existingScholarship.StringId}/relationships/participants"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(0); + } + + [Fact] + public async Task Skips_on_add_to_ToMany_relationship() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Scholarship existingScholarship = _fakers.Scholarship.Generate(); + List existingStudents = _fakers.Student.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Scholarships.Add(existingScholarship); + dbContext.Students.AddRange(existingStudents); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "students", + id = existingStudents[0].StringId + }, + new + { + type = "students", + id = existingStudents[1].StringId + } + } + }; + + string route = $"/scholarships/{existingScholarship.StringId}/relationships/participants"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(0); + } + + [Fact] + public async Task Skips_on_remove_from_ToMany_relationship() + { + // Arrange + var hitCounter = _testContext.Factory.Services.GetRequiredService(); + + Scholarship existingScholarship = _fakers.Scholarship.Generate(); + existingScholarship.Participants = _fakers.Student.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Scholarships.Add(existingScholarship); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "students", + id = existingScholarship.Participants[0].StringId + }, + new + { + type = "students", + id = existingScholarship.Participants[1].StringId + } + } + }; + + string route = $"/scholarships/{existingScholarship.StringId}/relationships/participants"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + hitCounter.DeserializeCount.Should().Be(0); + hitCounter.SerializeCount.Should().Be(0); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/Student.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/Student.cs new file mode 100644 index 0000000000..bcec15bf30 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/Student.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Serialization +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class Student : Identifiable + { + [Attr] + public string Name { get; set; } + + [Attr] + public string SocialSecurityNumber { get; set; } + + [HasOne] + public Scholarship Scholarship { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/StudentDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/StudentDefinition.cs new file mode 100644 index 0000000000..2552c51031 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/StudentDefinition.cs @@ -0,0 +1,40 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Serialization +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public sealed class StudentDefinition : JsonApiResourceDefinition + { + private readonly IEncryptionService _encryptionService; + private readonly SerializationHitCounter _hitCounter; + + public StudentDefinition(IResourceGraph resourceGraph, IEncryptionService encryptionService, SerializationHitCounter hitCounter) + : base(resourceGraph) + { + _encryptionService = encryptionService; + _hitCounter = hitCounter; + } + + public override void OnDeserialize(Student resource) + { + _hitCounter.IncrementDeserializeCount(); + + if (!string.IsNullOrEmpty(resource.SocialSecurityNumber)) + { + resource.SocialSecurityNumber = _encryptionService.Decrypt(resource.SocialSecurityNumber); + } + } + + public override void OnSerialize(Student resource) + { + _hitCounter.IncrementSerializeCount(); + + if (!string.IsNullOrEmpty(resource.SocialSecurityNumber)) + { + resource.SocialSecurityNumber = _encryptionService.Encrypt(resource.SocialSecurityNumber); + } + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/StudentsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/StudentsController.cs new file mode 100644 index 0000000000..0460c84f7f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ResourceDefinitions/Serialization/StudentsController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ResourceDefinitions.Serialization +{ + public sealed class StudentsController : JsonApiController + { + public StudentsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/Company.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/Company.cs index 1e4dad6f61..c2cd8b7b45 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/Company.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/Company.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; @@ -11,8 +12,7 @@ public sealed class Company : Identifiable, ISoftDeletable [Attr] public string Name { get; set; } - [Attr] - public bool IsSoftDeleted { get; set; } + public DateTimeOffset? SoftDeletedAt { get; set; } [HasMany] public ICollection Departments { get; set; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/Department.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/Department.cs index db2ec47c68..3065f461a9 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/Department.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/Department.cs @@ -1,3 +1,4 @@ +using System; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -10,8 +11,7 @@ public sealed class Department : Identifiable, ISoftDeletable [Attr] public string Name { get; set; } - [Attr] - public bool IsSoftDeleted { get; set; } + public DateTimeOffset? SoftDeletedAt { get; set; } [HasOne] public Company Company { get; set; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/ISoftDeletable.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/ISoftDeletable.cs index 8aa1b29847..69fbbc5b45 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/ISoftDeletable.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/ISoftDeletable.cs @@ -1,7 +1,11 @@ +using System; +using JetBrains.Annotations; + namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion { + [UsedImplicitly(ImplicitUseTargetFlags.Members)] public interface ISoftDeletable { - bool IsSoftDeleted { get; set; } + DateTimeOffset? SoftDeletedAt { get; set; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs new file mode 100644 index 0000000000..74a7e9c8b6 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionAwareResourceService.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Hooks; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion +{ + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public class SoftDeletionAwareResourceService : JsonApiResourceService + where TResource : class, IIdentifiable + { + private readonly ISystemClock _systemClock; + private readonly ITargetedFields _targetedFields; + private readonly IResourceRepositoryAccessor _repositoryAccessor; + private readonly IJsonApiRequest _request; + + public SoftDeletionAwareResourceService(ISystemClock systemClock, ITargetedFields targetedFields, 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) + { + _systemClock = systemClock; + _targetedFields = targetedFields; + _repositoryAccessor = repositoryAccessor; + _request = request; + } + + // To optimize performance, the default resource service does not always fetch all resources on write operations. + // We do that here, to assure a 404 error is thrown for soft-deleted resources. + + public override async Task CreateAsync(TResource resource, CancellationToken cancellationToken) + { + if (_targetedFields.Relationships.Any(relationship => IsSoftDeletable(relationship.RightType))) + { + await AssertResourcesToAssignInRelationshipsExistAsync(resource, cancellationToken); + } + + return await base.CreateAsync(resource, cancellationToken); + } + + public override async Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) + { + if (_targetedFields.Relationships.Any(relationship => IsSoftDeletable(relationship.RightType))) + { + await AssertResourcesToAssignInRelationshipsExistAsync(resource, cancellationToken); + } + + return await base.UpdateAsync(id, resource, cancellationToken); + } + + public override async Task SetRelationshipAsync(TId primaryId, string relationshipName, object secondaryResourceIds, + CancellationToken cancellationToken) + { + if (IsSoftDeletable(_request.Relationship.RightType)) + { + await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken); + } + + await base.SetRelationshipAsync(primaryId, relationshipName, secondaryResourceIds, cancellationToken); + } + + public override async Task AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds, + CancellationToken cancellationToken) + { + if (IsSoftDeletable(typeof(TResource))) + { + _ = await GetPrimaryResourceByIdAsync(primaryId, TopFieldSelection.OnlyIdAttribute, cancellationToken); + } + + if (IsSoftDeletable(_request.Relationship.RightType)) + { + await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken); + } + + await base.AddToToManyRelationshipAsync(primaryId, relationshipName, secondaryResourceIds, cancellationToken); + } + + public override async Task DeleteAsync(TId id, CancellationToken cancellationToken) + { + if (IsSoftDeletable(typeof(TResource))) + { + await SoftDeleteAsync(id, cancellationToken); + } + else + { + await base.DeleteAsync(id, cancellationToken); + } + } + + private async Task SoftDeleteAsync(TId id, CancellationToken cancellationToken) + { + TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(id, cancellationToken); + + ((ISoftDeletable)resourceFromDatabase).SoftDeletedAt = _systemClock.UtcNow; + + // A delete operation does not target any fields, so we can just pass resourceFromDatabase twice. + await _repositoryAccessor.UpdateAsync(resourceFromDatabase, resourceFromDatabase, cancellationToken); + } + + private static bool IsSoftDeletable(Type resourceType) + { + return typeof(ISoftDeletable).IsAssignableFrom(resourceType); + } + } + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + public class SoftDeletionAwareResourceService : SoftDeletionAwareResourceService, IResourceService + where TResource : class, IIdentifiable + { + public SoftDeletionAwareResourceService(ISystemClock systemClock, ITargetedFields targetedFields, IResourceRepositoryAccessor repositoryAccessor, + IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, + IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceHookExecutorFacade hookExecutor) + : base(systemClock, targetedFields, repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, + resourceChangeTracker, hookExecutor) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionDbContext.cs index 17e3207f61..d46e2d3605 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionDbContext.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionDbContext.cs @@ -1,6 +1,8 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +// @formatter:wrap_chained_method_calls chop_always + namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion { [UsedImplicitly(ImplicitUseTargetFlags.Members)] @@ -13,5 +15,14 @@ public SoftDeletionDbContext(DbContextOptions options) : base(options) { } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasQueryFilter(company => company.SoftDeletedAt == null); + + builder.Entity() + .HasQueryFilter(department => department.SoftDeletedAt == null); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionFakers.cs new file mode 100644 index 0000000000..d3f6c1ecec --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionFakers.cs @@ -0,0 +1,25 @@ +using System; +using Bogus; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_always +// @formatter:keep_existing_linebreaks true + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion +{ + internal sealed class SoftDeletionFakers : FakerContainer + { + private readonly Lazy> _lazyCompanyFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(company => company.Name, faker => faker.Company.CompanyName())); + + private readonly Lazy> _lazyDepartmentFaker = new Lazy>(() => + new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(department => department.Name, faker => faker.Commerce.Department())); + + public Faker Company => _lazyCompanyFaker.Value; + public Faker Department => _lazyDepartmentFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionResourceDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionResourceDefinition.cs deleted file mode 100644 index e23b650ac6..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionResourceDefinition.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class SoftDeletionResourceDefinition : JsonApiResourceDefinition - where TResource : class, IIdentifiable, ISoftDeletable - { - private readonly IResourceGraph _resourceGraph; - - public SoftDeletionResourceDefinition(IResourceGraph resourceGraph) - : base(resourceGraph) - { - _resourceGraph = resourceGraph; - } - - public override FilterExpression OnApplyFilter(FilterExpression existingFilter) - { - ResourceContext resourceContext = _resourceGraph.GetResourceContext(); - - AttrAttribute isSoftDeletedAttribute = - resourceContext.Attributes.Single(attribute => attribute.Property.Name == nameof(ISoftDeletable.IsSoftDeleted)); - - var isNotSoftDeleted = new ComparisonExpression(ComparisonOperator.Equals, new ResourceFieldChainExpression(isSoftDeletedAttribute), - new LiteralConstantExpression("false")); - - return existingFilter == null - ? (FilterExpression)isNotSoftDeleted - : new LogicalExpression(LogicalOperator.And, ArrayFactory.Create(isNotSoftDeleted, existingFilter)); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs index 8128d02d29..9cb0cd3dbc 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs @@ -1,12 +1,17 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; using System.Threading.Tasks; using FluentAssertions; -using JsonApiDotNetCore.Resources; +using FluentAssertions.Common; +using FluentAssertions.Extensions; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; @@ -15,7 +20,10 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion { public sealed class SoftDeletionTests : IClassFixture, SoftDeletionDbContext>> { + private static readonly DateTimeOffset SoftDeletionTime = 1.January(2001).ToDateTimeOffset(); + private readonly ExampleIntegrationTestContext, SoftDeletionDbContext> _testContext; + private readonly SoftDeletionFakers _fakers = new SoftDeletionFakers(); public SoftDeletionTests(ExampleIntegrationTestContext, SoftDeletionDbContext> testContext) { @@ -26,27 +34,22 @@ public SoftDeletionTests(ExampleIntegrationTestContext { - services.AddScoped, SoftDeletionResourceDefinition>(); - services.AddScoped, SoftDeletionResourceDefinition>(); + services.AddSingleton(new FrozenSystemClock + { + UtcNow = 1.January(2005).ToDateTimeOffset() + }); + + services.AddResourceService>(); + services.AddResourceService>(); }); } [Fact] - public async Task Can_get_primary_resources() + public async Task Get_primary_resources_excludes_soft_deleted() { // Arrange - var departments = new List - { - new Department - { - Name = "Sales", - IsSoftDeleted = true - }, - new Department - { - Name = "Marketing" - } - }; + List departments = _fakers.Department.Generate(2); + departments[0].SoftDeletedAt = SoftDeletionTime; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -68,25 +71,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_filter_in_primary_resources() + public async Task Filter_on_primary_resources_excludes_soft_deleted() { // Arrange - var departments = new List - { - new Department - { - Name = "Support" - }, - new Department - { - Name = "Sales", - IsSoftDeleted = true - }, - new Department - { - Name = "Marketing" - } - }; + List departments = _fakers.Department.Generate(3); + + departments[0].Name = "Support"; + + departments[1].Name = "Sales"; + departments[1].SoftDeletedAt = SoftDeletionTime; + + departments[2].Name = "Marketing"; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -108,14 +103,47 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_get_deleted_primary_resource_by_ID() + public async Task Get_primary_resources_with_include_excludes_soft_deleted() { // Arrange - var department = new Department + List companies = _fakers.Company.Generate(2); + + companies[0].SoftDeletedAt = SoftDeletionTime; + companies[0].Departments = _fakers.Department.Generate(1); + + companies[1].Departments = _fakers.Department.Generate(2); + companies[1].Departments.ElementAt(1).SoftDeletedAt = SoftDeletionTime; + + await _testContext.RunOnDatabaseAsync(async dbContext => { - Name = "Sales", - IsSoftDeleted = true - }; + await dbContext.ClearTableAsync(); + dbContext.Companies.AddRange(companies); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/companies?include=departments"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Type.Should().Be("companies"); + responseDocument.ManyData[0].Id.Should().Be(companies[1].StringId); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("departments"); + responseDocument.Included[0].Id.Should().Be(companies[1].Departments.ElementAt(0).StringId); + } + + [Fact] + public async Task Cannot_get_soft_deleted_primary_resource_by_ID() + { + // Arrange + Department department = _fakers.Department.Generate(); + department.SoftDeletedAt = SoftDeletionTime; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -137,28 +165,45 @@ await _testContext.RunOnDatabaseAsync(async dbContext => error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("The requested resource does not exist."); error.Detail.Should().Be($"Resource of type 'departments' with ID '{department.StringId}' does not exist."); - error.Source.Parameter.Should().BeNull(); } [Fact] - public async Task Can_get_secondary_resources() + public async Task Cannot_get_secondary_resources_for_soft_deleted_parent() { // Arrange - var company = new Company + Company company = _fakers.Company.Generate(); + company.SoftDeletedAt = SoftDeletionTime; + company.Departments = _fakers.Department.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => { - Departments = new List - { - new Department - { - Name = "Sales", - IsSoftDeleted = true - }, - new Department - { - Name = "Marketing" - } - } - }; + dbContext.Companies.Add(company); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/companies/{company.StringId}/departments"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'companies' with ID '{company.StringId}' does not exist."); + } + + [Fact] + public async Task Get_secondary_resources_excludes_soft_deleted() + { + // Arrange + Company company = _fakers.Company.Generate(); + company.Departments = _fakers.Department.Generate(2); + company.Departments.ElementAt(0).SoftDeletedAt = SoftDeletionTime; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -175,32 +220,24 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Id.Should().Be(company.Departments.Skip(1).Single().StringId); + responseDocument.ManyData[0].Id.Should().Be(company.Departments.ElementAt(1).StringId); } [Fact] - public async Task Cannot_get_secondary_resources_for_deleted_parent() + public async Task Cannot_get_secondary_resource_for_soft_deleted_parent() { // Arrange - var company = new Company - { - IsSoftDeleted = true, - Departments = new List - { - new Department - { - Name = "Marketing" - } - } - }; + Department department = _fakers.Department.Generate(); + department.SoftDeletedAt = SoftDeletionTime; + department.Company = _fakers.Company.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Companies.Add(company); + dbContext.Departments.Add(department); await dbContext.SaveChangesAsync(); }); - string route = $"/companies/{company.StringId}/departments"; + string route = $"/departments/{department.StringId}/company"; // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -213,54 +250,24 @@ await _testContext.RunOnDatabaseAsync(async dbContext => Error error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("The requested resource does not exist."); - error.Detail.Should().Be($"Resource of type 'companies' with ID '{company.StringId}' does not exist."); - error.Source.Parameter.Should().BeNull(); + error.Detail.Should().Be($"Resource of type 'departments' with ID '{department.StringId}' does not exist."); } [Fact] - public async Task Can_get_primary_resources_with_include() + public async Task Cannot_get_soft_deleted_secondary_resource() { // Arrange - var companies = new List - { - new Company - { - Name = "Acme Corporation", - IsSoftDeleted = true, - Departments = new List - { - new Department - { - Name = "Recruitment" - } - } - }, - new Company - { - Name = "AdventureWorks", - Departments = new List - { - new Department - { - Name = "Reception" - }, - new Department - { - Name = "Sales", - IsSoftDeleted = true - } - } - } - }; + Department department = _fakers.Department.Generate(); + department.Company = _fakers.Company.Generate(); + department.Company.SoftDeletedAt = SoftDeletionTime; await _testContext.RunOnDatabaseAsync(async dbContext => { - await dbContext.ClearTableAsync(); - dbContext.Companies.AddRange(companies); + dbContext.Departments.Add(department); await dbContext.SaveChangesAsync(); }); - const string route = "/companies?include=departments"; + string route = $"/departments/{department.StringId}/company"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -268,34 +275,46 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Type.Should().Be("companies"); - responseDocument.ManyData[0].Id.Should().Be(companies[1].StringId); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Type.Should().Be("departments"); - responseDocument.Included[0].Id.Should().Be(companies[1].Departments.First().StringId); + responseDocument.Data.Should().BeNull(); } [Fact] - public async Task Can_get_relationship() + public async Task Cannot_get_HasMany_relationship_for_soft_deleted_parent() { // Arrange - var company = new Company + Company company = _fakers.Company.Generate(); + company.SoftDeletedAt = SoftDeletionTime; + company.Departments = _fakers.Department.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => { - Departments = new List - { - new Department - { - Name = "Sales", - IsSoftDeleted = true - }, - new Department - { - Name = "Marketing" - } - } - }; + dbContext.Companies.Add(company); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/companies/{company.StringId}/relationships/departments"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'companies' with ID '{company.StringId}' does not exist."); + } + + [Fact] + public async Task Get_HasMany_relationship_excludes_soft_deleted() + { + // Arrange + Company company = _fakers.Company.Generate(); + company.Departments = _fakers.Department.Generate(2); + company.Departments.ElementAt(0).SoftDeletedAt = SoftDeletionTime; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -312,32 +331,24 @@ await _testContext.RunOnDatabaseAsync(async dbContext => httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Id.Should().Be(company.Departments.Skip(1).Single().StringId); + responseDocument.ManyData[0].Id.Should().Be(company.Departments.ElementAt(1).StringId); } [Fact] - public async Task Cannot_get_relationship_for_deleted_parent() + public async Task Cannot_get_HasOne_relationship_for_soft_deleted_parent() { // Arrange - var company = new Company - { - IsSoftDeleted = true, - Departments = new List - { - new Department - { - Name = "Marketing" - } - } - }; + Department department = _fakers.Department.Generate(); + department.SoftDeletedAt = SoftDeletionTime; + department.Company = _fakers.Company.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Companies.Add(company); + dbContext.Departments.Add(department); await dbContext.SaveChangesAsync(); }); - string route = $"/companies/{company.StringId}/relationships/departments"; + string route = $"/departments/{department.StringId}/relationships/company"; // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -350,22 +361,46 @@ await _testContext.RunOnDatabaseAsync(async dbContext => Error error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("The requested resource does not exist."); - error.Detail.Should().Be($"Resource of type 'companies' with ID '{company.StringId}' does not exist."); - error.Source.Parameter.Should().BeNull(); + error.Detail.Should().Be($"Resource of type 'departments' with ID '{department.StringId}' does not exist."); } [Fact] - public async Task Cannot_update_deleted_resource() + public async Task Get_HasOne_relationship_excludes_soft_deleted() { // Arrange - var company = new Company + Department department = _fakers.Department.Generate(); + department.Company = _fakers.Company.Generate(); + department.Company.SoftDeletedAt = SoftDeletionTime; + + await _testContext.RunOnDatabaseAsync(async dbContext => { - IsSoftDeleted = true - }; + dbContext.Departments.Add(department); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/departments/{department.StringId}/relationships/company"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.Should().BeNull(); + } + + [Fact] + public async Task Cannot_create_resource_with_HasMany_relationship_to_soft_deleted() + { + // Arrange + Department existingDepartment = _fakers.Department.Generate(); + existingDepartment.SoftDeletedAt = SoftDeletionTime; + + string newCompanyName = _fakers.Company.Generate().Name; await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Companies.Add(company); + dbContext.Departments.Add(existingDepartment); await dbContext.SaveChangesAsync(); }); @@ -374,18 +409,31 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "companies", - id = company.StringId, attributes = new { - name = "Umbrella Corporation" + name = newCompanyName + }, + relationships = new + { + departments = new + { + data = new[] + { + new + { + type = "departments", + id = existingDepartment.StringId + } + } + } } } }; - string route = "/companies/" + company.StringId; + const string route = "/companies"; // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); // Assert httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); @@ -394,40 +442,96 @@ await _testContext.RunOnDatabaseAsync(async dbContext => Error error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("The requested resource does not exist."); - error.Detail.Should().Be($"Resource of type 'companies' with ID '{company.StringId}' does not exist."); - error.Source.Parameter.Should().BeNull(); + error.Title.Should().Be("A related resource does not exist."); + + error.Detail.Should() + .Be($"Related resource of type 'departments' with ID '{existingDepartment.StringId}' in relationship 'departments' does not exist."); } [Fact] - public async Task Cannot_update_relationship_for_deleted_parent() + public async Task Cannot_create_resource_with_HasOne_relationship_to_soft_deleted() { // Arrange - var company = new Company + Company existingCompany = _fakers.Company.Generate(); + existingCompany.SoftDeletedAt = SoftDeletionTime; + + string newDepartmentName = _fakers.Department.Generate().Name; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Companies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new { - IsSoftDeleted = true, - Departments = new List + data = new { - new Department + type = "departments", + attributes = new + { + name = newDepartmentName + }, + relationships = new { - Name = "Marketing" + company = new + { + data = new + { + type = "companies", + id = existingCompany.StringId + } + } } } }; + const string route = "/departments"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'companies' with ID '{existingCompany.StringId}' in relationship 'company' does not exist."); + } + + [Fact] + public async Task Cannot_update_soft_deleted_resource() + { + // Arrange + Company existingCompany = _fakers.Company.Generate(); + existingCompany.SoftDeletedAt = SoftDeletionTime; + + string newCompanyName = _fakers.Company.Generate().Name; + await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Companies.Add(company); + dbContext.Companies.Add(existingCompany); await dbContext.SaveChangesAsync(); }); - string route = $"/companies/{company.StringId}/relationships/departments"; - var requestBody = new { - data = new object[0] + data = new + { + type = "companies", + id = existingCompany.StringId, + attributes = new + { + name = newCompanyName + } + } }; + string route = "/companies/" + existingCompany.StringId; + // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -439,8 +543,502 @@ await _testContext.RunOnDatabaseAsync(async dbContext => Error error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("The requested resource does not exist."); - error.Detail.Should().Be($"Resource of type 'companies' with ID '{company.StringId}' does not exist."); - error.Source.Parameter.Should().BeNull(); + error.Detail.Should().Be($"Resource of type 'companies' with ID '{existingCompany.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_update_resource_with_HasMany_relationship_to_soft_deleted() + { + // Arrange + Company existingCompany = _fakers.Company.Generate(); + + Department existingDepartment = _fakers.Department.Generate(); + existingDepartment.SoftDeletedAt = SoftDeletionTime; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingCompany, existingDepartment); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "companies", + id = existingCompany.StringId, + relationships = new + { + departments = new + { + data = new[] + { + new + { + type = "departments", + id = existingDepartment.StringId + } + } + } + } + } + }; + + string route = "/companies/" + existingCompany.StringId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + + error.Detail.Should() + .Be($"Related resource of type 'departments' with ID '{existingDepartment.StringId}' in relationship 'departments' does not exist."); + } + + [Fact] + public async Task Cannot_update_resource_with_HasOne_relationship_to_soft_deleted() + { + // Arrange + Department existingDepartment = _fakers.Department.Generate(); + + Company existingCompany = _fakers.Company.Generate(); + existingCompany.SoftDeletedAt = SoftDeletionTime; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingDepartment, existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "departments", + id = existingDepartment.StringId, + relationships = new + { + company = new + { + data = new + { + type = "companies", + id = existingCompany.StringId + } + } + } + } + }; + + string route = "/departments/" + existingDepartment.StringId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'companies' with ID '{existingCompany.StringId}' in relationship 'company' does not exist."); + } + + [Fact] + public async Task Cannot_update_HasMany_relationship_for_soft_deleted_parent() + { + // Arrange + Company existingCompany = _fakers.Company.Generate(); + existingCompany.SoftDeletedAt = SoftDeletionTime; + existingCompany.Departments = _fakers.Department.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Companies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new object[0] + }; + + string route = $"/companies/{existingCompany.StringId}/relationships/departments"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'companies' with ID '{existingCompany.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_update_HasMany_relationship_to_soft_deleted() + { + // Arrange + Company existingCompany = _fakers.Company.Generate(); + + Department existingDepartment = _fakers.Department.Generate(); + existingDepartment.SoftDeletedAt = SoftDeletionTime; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingCompany, existingDepartment); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "departments", + id = existingDepartment.StringId + } + } + }; + + string route = $"/companies/{existingCompany.StringId}/relationships/departments"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + + error.Detail.Should() + .Be($"Related resource of type 'departments' with ID '{existingDepartment.StringId}' in relationship 'departments' does not exist."); + } + + [Fact] + public async Task Cannot_update_HasOne_relationship_for_soft_deleted_parent() + { + // Arrange + Department existingDepartment = _fakers.Department.Generate(); + existingDepartment.SoftDeletedAt = SoftDeletionTime; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Departments.Add(existingDepartment); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object)null + }; + + string route = $"/departments/{existingDepartment.StringId}/relationships/company"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'departments' with ID '{existingDepartment.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_update_HasOne_relationship_to_soft_deleted() + { + // Arrange + Department existingDepartment = _fakers.Department.Generate(); + + Company existingCompany = _fakers.Company.Generate(); + existingCompany.SoftDeletedAt = SoftDeletionTime; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingDepartment, existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "companies", + id = existingCompany.StringId + } + }; + + string route = $"/departments/{existingDepartment.StringId}/relationships/company"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'companies' with ID '{existingCompany.StringId}' in relationship 'company' does not exist."); + } + + [Fact] + public async Task Cannot_add_to_ToMany_relationship_for_soft_deleted_parent() + { + // Arrange + Company existingCompany = _fakers.Company.Generate(); + existingCompany.SoftDeletedAt = SoftDeletionTime; + + Department existingDepartment = _fakers.Department.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingCompany, existingDepartment); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "departments", + id = existingDepartment.StringId + } + } + }; + + string route = $"/companies/{existingCompany.StringId}/relationships/departments"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'companies' with ID '{existingCompany.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_add_to_ToMany_relationship_with_soft_deleted() + { + // Arrange + Company existingCompany = _fakers.Company.Generate(); + + Department existingDepartment = _fakers.Department.Generate(); + existingDepartment.SoftDeletedAt = SoftDeletionTime; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddRange(existingCompany, existingDepartment); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "departments", + id = existingDepartment.StringId + } + } + }; + + string route = $"/companies/{existingCompany.StringId}/relationships/departments"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + + error.Detail.Should() + .Be($"Related resource of type 'departments' with ID '{existingDepartment.StringId}' in relationship 'departments' does not exist."); + } + + [Fact] + public async Task Cannot_remove_from_ToMany_relationship_for_soft_deleted_parent() + { + // Arrange + Company existingCompany = _fakers.Company.Generate(); + existingCompany.SoftDeletedAt = SoftDeletionTime; + existingCompany.Departments = _fakers.Department.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Companies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "departments", + id = existingCompany.Departments.ElementAt(0).StringId + } + } + }; + + string route = $"/companies/{existingCompany.StringId}/relationships/departments"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'companies' with ID '{existingCompany.StringId}' does not exist."); + } + + [Fact] + public async Task Cannot_remove_from_ToMany_relationship_with_soft_deleted() + { + // Arrange + Company existingCompany = _fakers.Company.Generate(); + existingCompany.Departments = _fakers.Department.Generate(1); + existingCompany.Departments.ElementAt(0).SoftDeletedAt = SoftDeletionTime; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Companies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "departments", + id = existingCompany.Departments.ElementAt(0).StringId + } + } + }; + + string route = $"/companies/{existingCompany.StringId}/relationships/departments"; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + + error.Detail.Should().Be( + $"Related resource of type 'departments' with ID '{existingCompany.Departments.ElementAt(0).StringId}' in relationship 'departments' does not exist."); + } + + [Fact] + public async Task Can_soft_delete_resource() + { + // Arrange + Company existingCompany = _fakers.Company.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Companies.Add(existingCompany); + await dbContext.SaveChangesAsync(); + }); + + string route = "/companies/" + existingCompany.StringId; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Company companyInDatabase = await dbContext.Companies.IgnoreQueryFilters().FirstWithIdAsync(existingCompany.Id); + + companyInDatabase.Name.Should().Be(existingCompany.Name); + companyInDatabase.SoftDeletedAt.Should().NotBeNull(); + }); + } + + [Fact] + public async Task Cannot_delete_soft_deleted_resource() + { + // Arrange + Department existingDepartment = _fakers.Department.Generate(); + existingDepartment.SoftDeletedAt = SoftDeletionTime; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Departments.Add(existingDepartment); + await dbContext.SaveChangesAsync(); + }); + + string route = "/departments/" + existingDepartment.StringId; + + // Act + (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + Error error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'departments' with ID '{existingDepartment.StringId}' does not exist."); } } } diff --git a/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs b/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs index faefa5c79b..603ac39aa3 100644 --- a/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs +++ b/test/UnitTests/Extensions/ServiceCollectionExtensionsTests.cs @@ -10,6 +10,7 @@ using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Building; using JsonApiDotNetCore.Services; @@ -170,6 +171,34 @@ public void AddResourceRepository_Registers_All_LongForm_Repository_Interfaces() Assert.IsType(provider.GetRequiredService(typeof(IResourceWriteRepository))); } + [Fact] + public void AddResourceDefinition_Registers_Shorthand_Definition_Interface() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddResourceDefinition(); + + // Assert + ServiceProvider provider = services.BuildServiceProvider(); + Assert.IsType(provider.GetRequiredService(typeof(IResourceDefinition))); + } + + [Fact] + public void AddResourceDefinition_Registers_LongForm_Definition_Interface() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddResourceDefinition(); + + // Assert + ServiceProvider provider = services.BuildServiceProvider(); + Assert.IsType(provider.GetRequiredService(typeof(IResourceDefinition))); + } + [Fact] public void AddJsonApi_With_Context_Uses_Resource_Type_Name_If_NoOtherSpecified() { @@ -421,6 +450,182 @@ public Task RemoveFromToManyRelationshipAsync(GuidResource primaryResource, ISet } } + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + private sealed class IntResourceDefinition : IResourceDefinition + { + public IReadOnlyCollection OnApplyIncludes(IReadOnlyCollection existingIncludes) + { + throw new NotImplementedException(); + } + + public FilterExpression OnApplyFilter(FilterExpression existingFilter) + { + throw new NotImplementedException(); + } + + public SortExpression OnApplySort(SortExpression existingSort) + { + throw new NotImplementedException(); + } + + public PaginationExpression OnApplyPagination(PaginationExpression existingPagination) + { + throw new NotImplementedException(); + } + + public SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet) + { + throw new NotImplementedException(); + } + + public QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters() + { + throw new NotImplementedException(); + } + + public IDictionary GetMeta(IntResource resource) + { + throw new NotImplementedException(); + } + + public Task OnPrepareWriteAsync(IntResource resource, OperationKind operationKind, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnSetToOneRelationshipAsync(IntResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, + OperationKind operationKind, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnSetToManyRelationshipAsync(IntResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + OperationKind operationKind, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnAddToRelationshipAsync(int leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnRemoveFromRelationshipAsync(IntResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnWritingAsync(IntResource resource, OperationKind operationKind, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnWriteSucceededAsync(IntResource resource, OperationKind operationKind, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public void OnDeserialize(IntResource resource) + { + throw new NotImplementedException(); + } + + public void OnSerialize(IntResource resource) + { + throw new NotImplementedException(); + } + } + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + private sealed class GuidResourceDefinition : IResourceDefinition + { + public IReadOnlyCollection OnApplyIncludes(IReadOnlyCollection existingIncludes) + { + throw new NotImplementedException(); + } + + public FilterExpression OnApplyFilter(FilterExpression existingFilter) + { + throw new NotImplementedException(); + } + + public SortExpression OnApplySort(SortExpression existingSort) + { + throw new NotImplementedException(); + } + + public PaginationExpression OnApplyPagination(PaginationExpression existingPagination) + { + throw new NotImplementedException(); + } + + public SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet) + { + throw new NotImplementedException(); + } + + public QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters() + { + throw new NotImplementedException(); + } + + public IDictionary GetMeta(GuidResource resource) + { + throw new NotImplementedException(); + } + + public Task OnPrepareWriteAsync(GuidResource resource, OperationKind operationKind, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnSetToOneRelationshipAsync(GuidResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, + OperationKind operationKind, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnSetToManyRelationshipAsync(GuidResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + OperationKind operationKind, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnAddToRelationshipAsync(Guid leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnRemoveFromRelationshipAsync(GuidResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnWritingAsync(GuidResource resource, OperationKind operationKind, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task OnWriteSucceededAsync(GuidResource resource, OperationKind operationKind, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public void OnDeserialize(GuidResource resource) + { + throw new NotImplementedException(); + } + + public void OnSerialize(GuidResource resource) + { + throw new NotImplementedException(); + } + } + [UsedImplicitly(ImplicitUseTargetFlags.Members)] private sealed class TestContext : DbContext { diff --git a/test/UnitTests/Models/ResourceConstructionTests.cs b/test/UnitTests/Models/ResourceConstructionTests.cs index 9d479a1ef9..f31d140029 100644 --- a/test/UnitTests/Models/ResourceConstructionTests.cs +++ b/test/UnitTests/Models/ResourceConstructionTests.cs @@ -33,7 +33,10 @@ public void When_resource_has_default_constructor_it_must_succeed() IResourceGraph graph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); - var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object, + var serviceContainer = new ServiceContainer(); + serviceContainer.AddService(typeof(IResourceDefinitionAccessor), new NeverResourceDefinitionAccessor()); + + var serializer = new RequestDeserializer(graph, new ResourceFactory(serviceContainer), new TargetedFields(), _mockHttpContextAccessor.Object, _requestMock.Object, options); var body = new @@ -63,7 +66,10 @@ public void When_resource_has_default_constructor_that_throws_it_must_fail() IResourceGraph graph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); - var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object, + var serviceContainer = new ServiceContainer(); + serviceContainer.AddService(typeof(IResourceDefinitionAccessor), new NeverResourceDefinitionAccessor()); + + var serializer = new RequestDeserializer(graph, new ResourceFactory(serviceContainer), new TargetedFields(), _mockHttpContextAccessor.Object, _requestMock.Object, options); var body = new @@ -95,7 +101,10 @@ public void When_resource_has_constructor_with_string_parameter_it_must_fail() IResourceGraph graph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); - var serializer = new RequestDeserializer(graph, new ResourceFactory(new ServiceContainer()), new TargetedFields(), _mockHttpContextAccessor.Object, + var serviceContainer = new ServiceContainer(); + serviceContainer.AddService(typeof(IResourceDefinitionAccessor), new NeverResourceDefinitionAccessor()); + + var serializer = new RequestDeserializer(graph, new ResourceFactory(serviceContainer), new TargetedFields(), _mockHttpContextAccessor.Object, _requestMock.Object, options); var body = new diff --git a/test/UnitTests/NeverResourceDefinitionAccessor.cs b/test/UnitTests/NeverResourceDefinitionAccessor.cs new file mode 100644 index 0000000000..ddc89a95ba --- /dev/null +++ b/test/UnitTests/NeverResourceDefinitionAccessor.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace UnitTests +{ + internal sealed class NeverResourceDefinitionAccessor : IResourceDefinitionAccessor + { + public IReadOnlyCollection OnApplyIncludes(Type resourceType, IReadOnlyCollection existingIncludes) + { + return existingIncludes; + } + + public FilterExpression OnApplyFilter(Type resourceType, FilterExpression existingFilter) + { + return existingFilter; + } + + public SortExpression OnApplySort(Type resourceType, SortExpression existingSort) + { + return existingSort; + } + + public PaginationExpression OnApplyPagination(Type resourceType, PaginationExpression existingPagination) + { + return existingPagination; + } + + public SparseFieldSetExpression OnApplySparseFieldSet(Type resourceType, SparseFieldSetExpression existingSparseFieldSet) + { + return existingSparseFieldSet; + } + + public object GetQueryableHandlerForQueryStringParameter(Type resourceType, string parameterName) + { + return new QueryStringParameterHandlers(); + } + + public IDictionary GetMeta(Type resourceType, IIdentifiable resourceInstance) + { + return null; + } + + public Task OnPrepareWriteAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable rightResourceId, OperationKind operationKind, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.FromResult(rightResourceId); + } + + public Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + OperationKind operationKind, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnWritingAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnWriteSucceededAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public void OnDeserialize(IIdentifiable resource) + { + } + + public void OnSerialize(IIdentifiable resource) + { + } + } +} diff --git a/test/UnitTests/ResourceHooks/HooksTestsSetup.cs b/test/UnitTests/ResourceHooks/HooksTestsSetup.cs index 018ece0963..d9f288b9a2 100644 --- a/test/UnitTests/ResourceHooks/HooksTestsSetup.cs +++ b/test/UnitTests/ResourceHooks/HooksTestsSetup.cs @@ -15,7 +15,6 @@ using JsonApiDotNetCoreExample.Data; using JsonApiDotNetCoreExample.Models; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -218,8 +217,7 @@ private void SetupProcessorFactoryForResourceDefinition(Mock CreateTestRepository(AppDbContext dbContext, IResourceGraph resourceGraph) where TModel : class, IIdentifiable { - IServiceProvider serviceProvider = ((IInfrastructure)dbContext).Instance; - var resourceFactory = new ResourceFactory(serviceProvider); + var resourceFactory = new TestResourceFactory(); IDbContextResolver resolver = CreateTestDbResolver(dbContext); var targetedFields = new TargetedFields(); diff --git a/test/UnitTests/Serialization/SerializerTestsSetup.cs b/test/UnitTests/Serialization/SerializerTestsSetup.cs index a1d33a89e3..ada0a8d7f0 100644 --- a/test/UnitTests/Serialization/SerializerTestsSetup.cs +++ b/test/UnitTests/Serialization/SerializerTestsSetup.cs @@ -56,10 +56,16 @@ protected ResponseSerializer GetResponseSerializer(IEnumerable(meta, link, includedBuilder, fieldsToSerialize, resourceObjectBuilder, new JsonApiOptions()); + return new ResponseSerializer(meta, link, includedBuilder, fieldsToSerialize, resourceObjectBuilder, resourceDefinitionAccessor, jsonApiOptions); } protected ResponseResourceObjectBuilder GetResponseResourceObjectBuilder(IEnumerable> inclusionChains = null, diff --git a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs b/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs index 59ccd70675..1d45e92ffd 100644 --- a/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs +++ b/test/UnitTests/Serialization/Server/RequestDeserializerTests.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.ComponentModel.Design; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; @@ -20,8 +19,8 @@ public sealed class RequestDeserializerTests : DeserializerTestsSetup public RequestDeserializerTests() { - _deserializer = new RequestDeserializer(ResourceGraph, new ResourceFactory(new ServiceContainer()), _fieldsManagerMock.Object, - MockHttpContextAccessor.Object, _requestMock.Object, new JsonApiOptions()); + _deserializer = new RequestDeserializer(ResourceGraph, new TestResourceFactory(), _fieldsManagerMock.Object, MockHttpContextAccessor.Object, + _requestMock.Object, new JsonApiOptions()); } [Fact] diff --git a/test/UnitTests/TestResourceFactory.cs b/test/UnitTests/TestResourceFactory.cs new file mode 100644 index 0000000000..b27e334650 --- /dev/null +++ b/test/UnitTests/TestResourceFactory.cs @@ -0,0 +1,30 @@ +using System; +using System.Linq.Expressions; +using JsonApiDotNetCore.Resources; + +namespace UnitTests +{ + internal sealed class TestResourceFactory : IResourceFactory + { + public IIdentifiable CreateInstance(Type resourceType) + { + return (IIdentifiable)Activator.CreateInstance(resourceType); + } + + public TResource CreateInstance() + where TResource : IIdentifiable + { + return (TResource)Activator.CreateInstance(typeof(TResource)); + } + + public NewExpression CreateNewExpression(Type resourceType) + { + return Expression.New(resourceType); + } + + public IResourceDefinitionAccessor GetResourceDefinitionAccessor() + { + return new NeverResourceDefinitionAccessor(); + } + } +}