From 3751a4c3d0e948005feb70acac910bae178e7319 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 9 Sep 2020 17:51:52 +0200 Subject: [PATCH] Fixes in resource definition callbacks --- .../Queries/Internal/QueryLayerComposer.cs | 12 +- .../EntityFrameworkCoreRepository.cs | 2 +- .../Repositories/IResourceWriteRepository.cs | 4 +- .../Services/JsonApiResourceService.cs | 17 +- .../AppDbContextExtensions.cs | 5 +- .../SoftDeletion/CompaniesController.cs | 16 + .../IntegrationTests/SoftDeletion/Company.cs | 18 + .../SoftDeletion/CompanyResourceDefinition.cs | 30 ++ .../SoftDeletion/Department.cs | 17 + .../DepartmentResourceDefinition.cs | 30 ++ .../SoftDeletion/DepartmentsController.cs | 16 + .../SoftDeletion/SoftDeletionDbContext.cs | 14 + .../SoftDeletion/SoftDeletionTests.cs | 440 ++++++++++++++++++ 13 files changed, 607 insertions(+), 14 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/CompaniesController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/Company.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/CompanyResourceDefinition.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/Department.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/DepartmentResourceDefinition.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/DepartmentsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionDbContext.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs index a9061e9380..1cbbaa47d4 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs @@ -189,10 +189,12 @@ public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, primaryProjection[secondaryRelationship] = secondaryLayer; primaryProjection[primaryIdAttribute] = null; + var primaryFilter = GetFilter(Array.Empty(), primaryResourceContext); + return new QueryLayer(primaryResourceContext) { Include = RewriteIncludeForSecondaryEndpoint(innerInclude, secondaryRelationship), - Filter = CreateFilterById(primaryId, primaryResourceContext), + Filter = IncludeFilterById(primaryId, primaryResourceContext, primaryFilter), Projection = primaryProjection }; } @@ -206,12 +208,16 @@ private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression r return new IncludeExpression(new[] {parentElement}); } - private FilterExpression CreateFilterById(TId id, ResourceContext resourceContext) + private FilterExpression IncludeFilterById(TId id, ResourceContext resourceContext, FilterExpression existingFilter) { var primaryIdAttribute = resourceContext.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); - return new ComparisonExpression(ComparisonOperator.Equals, + FilterExpression filterById = new ComparisonExpression(ComparisonOperator.Equals, new ResourceFieldChainExpression(primaryIdAttribute), new LiteralConstantExpression(id.ToString())); + + return existingFilter == null + ? filterById + : new LogicalExpression(LogicalOperator.And, new[] {filterById, existingFilter}); } public IDictionary GetSecondaryProjectionForRelationshipEndpoint(ResourceContext secondaryResourceContext) diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index e7c081efa3..81823efdff 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -285,7 +285,7 @@ private IIdentifiable GetTrackedHasOneRelationshipValue(IIdentifiable relationsh } /// - public async Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds) + public async Task UpdateRelationshipAsync(object parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds) { _traceWriter.LogMethodStart(new {parent, relationship, relationshipIds}); if (parent == null) throw new ArgumentNullException(nameof(parent)); diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index 301ba6ef7f..27e46af19e 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -32,9 +32,9 @@ public interface IResourceWriteRepository Task UpdateAsync(TResource requestResource, TResource databaseResource); /// - /// Updates relationships in the underlying data store. + /// Updates a relationship in the underlying data store. /// - Task UpdateRelationshipsAsync(object parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds); + Task UpdateRelationshipAsync(object parent, RelationshipAttribute relationship, IReadOnlyCollection relationshipIds); /// /// Deletes a resource from the underlying data store. diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index a3e42c3c1d..e72377f877 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -156,7 +156,7 @@ private async Task GetPrimaryResourceById(TId id, bool allowTopSparse var primaryLayer = _queryLayerComposer.Compose(_request.PrimaryResource); primaryLayer.Sort = null; primaryLayer.Pagination = null; - primaryLayer.Filter = CreateFilterById(id); + primaryLayer.Filter = IncludeFilterById(id, primaryLayer.Filter); if (!allowTopSparseFieldSet && primaryLayer.Projection != null) { @@ -176,12 +176,16 @@ private async Task GetPrimaryResourceById(TId id, bool allowTopSparse return primaryResource; } - private FilterExpression CreateFilterById(TId id) + private FilterExpression IncludeFilterById(TId id, FilterExpression existingFilter) { var primaryIdAttribute = _request.PrimaryResource.Attributes.Single(a => a.Property.Name == nameof(Identifiable.Id)); - return new ComparisonExpression(ComparisonOperator.Equals, + FilterExpression filterById = new ComparisonExpression(ComparisonOperator.Equals, new ResourceFieldChainExpression(primaryIdAttribute), new LiteralConstantExpression(id.ToString())); + + return existingFilter == null + ? filterById + : new LogicalExpression(LogicalOperator.And, new[] {filterById, existingFilter}); } /// @@ -279,12 +283,15 @@ public virtual async Task UpdateAsync(TId id, TResource requestResour // triggered by PATCH /articles/1/relationships/{relationshipName} public virtual async Task UpdateRelationshipAsync(TId id, string relationshipName, object relationships) { - _traceWriter.LogMethodStart(new {id, relationshipName, related = relationships}); + _traceWriter.LogMethodStart(new {id, relationshipName, relationships}); if (relationshipName == null) throw new ArgumentNullException(nameof(relationshipName)); AssertRelationshipExists(relationshipName); var secondaryLayer = _queryLayerComposer.Compose(_request.SecondaryResource); + secondaryLayer.Projection = _queryLayerComposer.GetSecondaryProjectionForRelationshipEndpoint(_request.SecondaryResource); + secondaryLayer.Include = null; + var primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); primaryLayer.Projection = null; @@ -306,7 +313,7 @@ public virtual async Task UpdateRelationshipAsync(TId id, string relationshipNam : ((IEnumerable) relationships).Select(e => e.StringId).ToArray(); } - await _repository.UpdateRelationshipsAsync(primaryResource, _request.Relationship, relationshipIds ?? Array.Empty()); + await _repository.UpdateRelationshipAsync(primaryResource, _request.Relationship, relationshipIds ?? Array.Empty()); if (_hookExecutor != null && primaryResource != null) { diff --git a/test/JsonApiDotNetCoreExampleTests/AppDbContextExtensions.cs b/test/JsonApiDotNetCoreExampleTests/AppDbContextExtensions.cs index 092dcd49ae..87e669ec36 100644 --- a/test/JsonApiDotNetCoreExampleTests/AppDbContextExtensions.cs +++ b/test/JsonApiDotNetCoreExampleTests/AppDbContextExtensions.cs @@ -1,6 +1,5 @@ using System; using System.Threading.Tasks; -using JsonApiDotNetCoreExample.Data; using Microsoft.EntityFrameworkCore; using Npgsql; @@ -8,7 +7,7 @@ namespace JsonApiDotNetCoreExampleTests { public static class AppDbContextExtensions { - public static async Task ClearTableAsync(this AppDbContext dbContext) where TEntity : class + public static async Task ClearTableAsync(this DbContext dbContext) where TEntity : class { var entityType = dbContext.Model.FindEntityType(typeof(TEntity)); if (entityType == null) @@ -30,7 +29,7 @@ public static async Task ClearTableAsync(this AppDbContext dbContext) w } } - public static void ClearTable(this AppDbContext dbContext) where TEntity : class + public static void ClearTable(this DbContext dbContext) where TEntity : class { var entityType = dbContext.Model.FindEntityType(typeof(TEntity)); if (entityType == null) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/CompaniesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/CompaniesController.cs new file mode 100644 index 0000000000..17fe2da387 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/CompaniesController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion +{ + public sealed class CompaniesController : JsonApiController + { + public CompaniesController(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 new file mode 100644 index 0000000000..585c965b3d --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/Company.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion +{ + public sealed class Company : Identifiable + { + [Attr] + public string Name { get; set; } + + [Attr] + public bool IsSoftDeleted { get; set; } + + [HasMany] + public ICollection Departments { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/CompanyResourceDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/CompanyResourceDefinition.cs new file mode 100644 index 0000000000..c424a3ac98 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/CompanyResourceDefinition.cs @@ -0,0 +1,30 @@ +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion +{ + public sealed class CompanyResourceDefinition : ResourceDefinition + { + private readonly IResourceGraph _resourceGraph; + + public CompanyResourceDefinition(IResourceGraph resourceGraph) : base(resourceGraph) + { + _resourceGraph = resourceGraph; + } + + public override FilterExpression OnApplyFilter(FilterExpression existingFilter) + { + var resourceContext = _resourceGraph.GetResourceContext(); + var isSoftDeletedAttribute = resourceContext.Attributes.Single(attribute => attribute.Property.Name == nameof(Company.IsSoftDeleted)); + + var isNotSoftDeleted = new ComparisonExpression(ComparisonOperator.Equals, + new ResourceFieldChainExpression(isSoftDeletedAttribute), new LiteralConstantExpression("false")); + + return existingFilter == null + ? (FilterExpression) isNotSoftDeleted + : new LogicalExpression(LogicalOperator.And, new[] {isNotSoftDeleted, existingFilter}); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/Department.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/Department.cs new file mode 100644 index 0000000000..e2688c4110 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/Department.cs @@ -0,0 +1,17 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion +{ + public sealed class Department : Identifiable + { + [Attr] + public string Name { get; set; } + + [Attr] + public bool IsSoftDeleted { get; set; } + + [HasOne] + public Company Company { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/DepartmentResourceDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/DepartmentResourceDefinition.cs new file mode 100644 index 0000000000..987e955629 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/DepartmentResourceDefinition.cs @@ -0,0 +1,30 @@ +using System.Linq; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion +{ + public sealed class DepartmentResourceDefinition : ResourceDefinition + { + private readonly IResourceGraph _resourceGraph; + + public DepartmentResourceDefinition(IResourceGraph resourceGraph) : base(resourceGraph) + { + _resourceGraph = resourceGraph; + } + + public override FilterExpression OnApplyFilter(FilterExpression existingFilter) + { + var resourceContext = _resourceGraph.GetResourceContext(); + var isSoftDeletedAttribute = resourceContext.Attributes.Single(attribute => attribute.Property.Name == nameof(Department.IsSoftDeleted)); + + var isNotSoftDeleted = new ComparisonExpression(ComparisonOperator.Equals, + new ResourceFieldChainExpression(isSoftDeletedAttribute), new LiteralConstantExpression("false")); + + return existingFilter == null + ? (FilterExpression) isNotSoftDeleted + : new LogicalExpression(LogicalOperator.And, new[] {isNotSoftDeleted, existingFilter}); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/DepartmentsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/DepartmentsController.cs new file mode 100644 index 0000000000..01a77f9b08 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/DepartmentsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion +{ + public sealed class DepartmentsController : JsonApiController + { + public DepartmentsController(IJsonApiOptions options, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionDbContext.cs new file mode 100644 index 0000000000..df5c8cdcf5 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionDbContext.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion +{ + public sealed class SoftDeletionDbContext : DbContext + { + public DbSet Companies { get; set; } + public DbSet Departments { get; set; } + + public SoftDeletionDbContext(DbContextOptions options) : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs new file mode 100644 index 0000000000..a891b5065d --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/SoftDeletion/SoftDeletionTests.cs @@ -0,0 +1,440 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.SoftDeletion +{ + public sealed class SoftDeletionTests : IClassFixture, SoftDeletionDbContext>> + { + private readonly IntegrationTestContext, SoftDeletionDbContext> _testContext; + + public SoftDeletionTests(IntegrationTestContext, SoftDeletionDbContext> testContext) + { + _testContext = testContext; + + testContext.ConfigureServicesAfterStartup(services => + { + services.AddScoped, CompanyResourceDefinition>(); + services.AddScoped, DepartmentResourceDefinition>(); + }); + } + + [Fact] + public async Task Can_get_primary_resources() + { + // Arrange + var departments = new List + { + new Department + { + Name = "Sales", + IsSoftDeleted = true + }, + new Department + { + Name = "Marketing" + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Departments.AddRange(departments); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/departments"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(departments[1].StringId); + } + + [Fact] + public async Task Can_filter_in_primary_resources() + { + // Arrange + var departments = new List + { + new Department + { + Name = "Support" + }, + new Department + { + Name = "Sales", + IsSoftDeleted = true + }, + new Department + { + Name = "Marketing" + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Departments.AddRange(departments); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/departments?filter=startsWith(name,'S')"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(departments[0].StringId); + } + + [Fact] + public async Task Cannot_get_deleted_primary_resource_by_ID() + { + // Arrange + var department = new Department + { + Name = "Sales", + IsSoftDeleted = true + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Departments.Add(department); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/departments/" + department.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be($"Resource of type 'departments' with ID '{department.StringId}' does not exist."); + responseDocument.Errors[0].Source.Parameter.Should().BeNull(); + } + + [Fact] + public async Task Can_get_secondary_resources() + { + // Arrange + var company = new Company + { + Departments = new List + { + new Department + { + Name = "Sales", + IsSoftDeleted = true + }, + new Department + { + Name = "Marketing" + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Companies.Add(company); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/companies/{company.StringId}/departments"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(company.Departments.Skip(1).Single().StringId); + } + + [Fact] + public async Task Cannot_get_secondary_resources_for_deleted_parent() + { + // Arrange + var company = new Company + { + IsSoftDeleted = true, + Departments = new List + { + new Department + { + Name = "Marketing" + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Companies.Add(company); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/companies/{company.StringId}/departments"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be($"Resource of type 'companies' with ID '{company.StringId}' does not exist."); + responseDocument.Errors[0].Source.Parameter.Should().BeNull(); + } + + [Fact] + public async Task Can_get_primary_resources_with_include() + { + // 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 + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Companies.AddRange(companies); + + await dbContext.SaveChangesAsync(); + }); + + var route = "/companies?include=departments"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Type.Should().Be("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); + } + + [Fact] + public async Task Can_get_relationship() + { + // Arrange + var company = new Company + { + Departments = new List + { + new Department + { + Name = "Sales", + IsSoftDeleted = true + }, + new Department + { + Name = "Marketing" + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Companies.Add(company); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/companies/{company.StringId}/relationships/departments"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Id.Should().Be(company.Departments.Skip(1).Single().StringId); + } + + [Fact] + public async Task Cannot_get_relationship_for_deleted_parent() + { + // Arrange + var company = new Company + { + IsSoftDeleted = true, + Departments = new List + { + new Department + { + Name = "Marketing" + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Companies.Add(company); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/companies/{company.StringId}/relationships/departments"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be($"Resource of type 'companies' with ID '{company.StringId}' does not exist."); + responseDocument.Errors[0].Source.Parameter.Should().BeNull(); + } + + [Fact] + public async Task Cannot_update_deleted_resource() + { + // Arrange + var company = new Company + { + IsSoftDeleted = true + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Companies.Add(company); + + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "companies", + id = company.StringId, + attributes = new Dictionary + { + {"name", "Umbrella Corporation"} + } + } + }; + + var route = "/companies/" + company.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be($"Resource of type 'companies' with ID '{company.StringId}' does not exist."); + responseDocument.Errors[0].Source.Parameter.Should().BeNull(); + } + + [Fact] + public async Task Cannot_update_relationship_for_deleted_parent() + { + // Arrange + var company = new Company + { + IsSoftDeleted = true, + Departments = new List + { + new Department + { + Name = "Marketing" + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Companies.Add(company); + + await dbContext.SaveChangesAsync(); + }); + + var route = $"/companies/{company.StringId}/relationships/departments"; + + var requestBody = new + { + data = new object[0] + }; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + responseDocument.Errors[0].StatusCode.Should().Be(HttpStatusCode.NotFound); + responseDocument.Errors[0].Title.Should().Be("The requested resource does not exist."); + responseDocument.Errors[0].Detail.Should().Be($"Resource of type 'companies' with ID '{company.StringId}' does not exist."); + responseDocument.Errors[0].Source.Parameter.Should().BeNull(); + } + } +}