diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings index 595cae92d8..bd2a8fe2d1 100644 --- a/JsonApiDotNetCore.sln.DotSettings +++ b/JsonApiDotNetCore.sln.DotSettings @@ -637,5 +637,6 @@ $left$ = $right$; True True True + True True diff --git a/src/JsonApiDotNetCore/Errors/DataConcurrencyException.cs b/src/JsonApiDotNetCore/Errors/DataConcurrencyException.cs new file mode 100644 index 0000000000..82baface2e --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/DataConcurrencyException.cs @@ -0,0 +1,23 @@ +using System; +using System.Net; +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Errors +{ + /// + /// The error that is thrown when data has been modified on the server since the resource was retrieved. + /// + [PublicAPI] + public sealed class DataConcurrencyException : JsonApiException + { + public DataConcurrencyException(Exception exception) + : base(new ErrorObject(HttpStatusCode.Conflict) + { + Title = "The concurrency token is missing or does not match the server version. " + + "This indicates that data has been modified since the resource was retrieved." + }, exception) + { + } + } +} diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 2092f8935a..61dc6d1660 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Linq.Expressions; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; @@ -207,7 +208,7 @@ public virtual async Task CreateAsync(TResource resourceFromRequest, TResource r DbSet dbSet = _dbContext.Set(); await dbSet.AddAsync(resourceForDatabase, cancellationToken); - await SaveChangesAsync(cancellationToken); + await SaveChangesAsync(cancellationToken, false); await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); @@ -283,15 +284,43 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r attribute.SetValue(resourceFromDatabase, attribute.GetValue(resourceFromRequest)); } + bool hasConcurrencyToken = RestoreConcurrencyToken(resourceFromRequest, resourceFromDatabase); + await _resourceDefinitionAccessor.OnWritingAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); - await SaveChangesAsync(cancellationToken); + await SaveChangesAsync(cancellationToken, hasConcurrencyToken); await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); _dbContext.ResetChangeTracker(); } + private bool RestoreConcurrencyToken(TResource resourceFromRequest, TResource resourceFromDatabase) + { + bool hasConcurrencyToken = false; + + foreach (var propertyEntry in _dbContext.Entry(resourceFromDatabase).Properties) + { + if (propertyEntry.Metadata.IsConcurrencyToken) + { + // Overwrite the ConcurrencyToken coming from database with the one from the request body. + // If they are different, EF Core throws a DbUpdateConcurrencyException on save. + + PropertyInfo? concurrencyTokenProperty = typeof(TResource).GetProperty(propertyEntry.Metadata.PropertyInfo.Name); + + if (concurrencyTokenProperty != null) + { + object? concurrencyTokenFromRequest = concurrencyTokenProperty.GetValue(resourceFromRequest); + propertyEntry.OriginalValue = concurrencyTokenFromRequest; + + hasConcurrencyToken = true; + } + } + } + + return hasConcurrencyToken; + } + protected void AssertIsNotClearingRequiredToOneRelationship(RelationshipAttribute relationship, TResource leftResource, object? rightValue) { if (relationship is HasOneAttribute) @@ -341,7 +370,7 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke _dbContext.Remove(resourceTracked); - await SaveChangesAsync(cancellationToken); + await SaveChangesAsync(cancellationToken, false); await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceTracked, WriteOperationKind.DeleteResource, cancellationToken); } @@ -412,7 +441,7 @@ public virtual async Task SetRelationshipAsync(TResource leftResource, object? r await _resourceDefinitionAccessor.OnWritingAsync(leftResource, WriteOperationKind.SetRelationship, cancellationToken); - await SaveChangesAsync(cancellationToken); + await SaveChangesAsync(cancellationToken, false); await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResource, WriteOperationKind.SetRelationship, cancellationToken); } @@ -445,7 +474,7 @@ public virtual async Task AddToToManyRelationshipAsync(TId leftId, ISet Disks => Set(); + public DbSet Partitions => Set(); + + public ConcurrencyDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + // https://www.npgsql.org/efcore/modeling/concurrency.html + + builder.Entity() + .UseXminAsConcurrencyToken(); + + builder.Entity() + .UseXminAsConcurrencyToken(); + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/ConcurrencyFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/ConcurrencyFakers.cs new file mode 100644 index 0000000000..ad526c475f --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/ConcurrencyFakers.cs @@ -0,0 +1,31 @@ +using System; +using Bogus; +using JsonApiDotNetCore; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_always +// @formatter:keep_existing_linebreaks true + +namespace JsonApiDotNetCoreTests.IntegrationTests.ConcurrencyTokens +{ + internal sealed class ConcurrencyFakers : FakerContainer + { + private const ulong OneGigabyte = 1024 * 1024 * 1024; + private static readonly string[] KnownFileSystems = ArrayFactory.Create("NTFS", "FAT32", "ext4", "XFS", "ZFS", "btrfs"); + + private readonly Lazy> _lazyDiskFaker = new(() => + new Faker().UseSeed(GetFakerSeed()) + .RuleFor(disk => disk.Manufacturer, faker => faker.Company.CompanyName()) + .RuleFor(disk => disk.SerialCode, faker => faker.System.ApplePushToken())); + + private readonly Lazy> _lazyPartitionFaker = new(() => + new Faker().UseSeed(GetFakerSeed()) + .RuleFor(partition => partition.MountPoint, faker => faker.System.DirectoryPath()) + .RuleFor(partition => partition.FileSystem, faker => faker.PickRandom(KnownFileSystems)) + .RuleFor(partition => partition.CapacityInBytes, faker => faker.Random.ULong(OneGigabyte * 50, OneGigabyte * 100)) + .RuleFor(partition => partition.FreeSpaceInBytes, faker => faker.Random.ULong(OneGigabyte * 10, OneGigabyte * 40))); + + public Faker Disk => _lazyDiskFaker.Value; + public Faker Partition => _lazyPartitionFaker.Value; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/ConcurrencyTokenTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/ConcurrencyTokenTests.cs new file mode 100644 index 0000000000..efd254fbf6 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/ConcurrencyTokenTests.cs @@ -0,0 +1,643 @@ +using System.Net; +using System.Threading.Tasks; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ConcurrencyTokens +{ + public sealed class ConcurrencyTokenTests : IClassFixture, ConcurrencyDbContext>> + { + private readonly IntegrationTestContext, ConcurrencyDbContext> _testContext; + private readonly ConcurrencyFakers _fakers = new(); + + public ConcurrencyTokenTests(IntegrationTestContext, ConcurrencyDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + } + + [Fact] + public async Task Can_get_primary_resource_by_ID_with_include() + { + // Arrange + Disk disk = _fakers.Disk.Generate(); + disk.Partitions = _fakers.Partition.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Disks.Add(disk); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/disks/{disk.StringId}?include=partitions"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("disks"); + responseDocument.Data.SingleValue.Id.Should().Be(disk.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("manufacturer").With(value => value.Should().Be(disk.Manufacturer)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("concurrencyToken").With(value => value.Should().Be(disk.xmin)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); + + responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included[0].Type.Should().Be("partitions"); + responseDocument.Included[0].Id.Should().Be(disk.Partitions[0].StringId); + responseDocument.Included[0].Attributes.ShouldContainKey("mountPoint").With(value => value.Should().Be(disk.Partitions[0].MountPoint)); + responseDocument.Included[0].Attributes.ShouldContainKey("fileSystem").With(value => value.Should().Be(disk.Partitions[0].FileSystem)); + responseDocument.Included[0].Attributes.ShouldContainKey("capacityInBytes").With(value => value.Should().Be(disk.Partitions[0].CapacityInBytes)); + responseDocument.Included[0].Attributes.ShouldContainKey("freeSpaceInBytes").With(value => value.Should().Be(disk.Partitions[0].FreeSpaceInBytes)); + responseDocument.Included[0].Attributes.ShouldContainKey("concurrencyToken").With(value => value.Should().Be(disk.Partitions[0].xmin)); + responseDocument.Included[0].Relationships.ShouldNotBeEmpty(); + } + + [Fact] + public async Task Can_create_resource() + { + // Arrange + string newManufacturer = _fakers.Disk.Generate().Manufacturer; + string newSerialCode = _fakers.Disk.Generate().SerialCode; + + var requestBody = new + { + data = new + { + type = "disks", + attributes = new + { + manufacturer = newManufacturer, + serialCode = newSerialCode + } + } + }; + + const string route = "/disks"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("disks"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("manufacturer").With(value => value.Should().Be(newManufacturer)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("serialCode").With(value => value.Should().Be(newSerialCode)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("concurrencyToken").With(value => value.As().Should().BeGreaterThan(0)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); + + long newDiskId = long.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Disk diskInDatabase = await dbContext.Disks.FirstWithIdAsync(newDiskId); + + diskInDatabase.Manufacturer.Should().Be(newManufacturer); + diskInDatabase.SerialCode.Should().Be(newSerialCode); + diskInDatabase.xmin.Should().BeGreaterThan(0); + }); + } + + [Fact] + public async Task Can_create_resource_with_ignored_token() + { + // Arrange + string newManufacturer = _fakers.Disk.Generate().Manufacturer; + string newSerialCode = _fakers.Disk.Generate().SerialCode; + const uint ignoredToken = 98765432; + + var requestBody = new + { + data = new + { + type = "disks", + attributes = new + { + manufacturer = newManufacturer, + serialCode = newSerialCode, + concurrencyToken = ignoredToken + } + } + }; + + const string route = "/disks"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("disks"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("manufacturer").With(value => value.Should().Be(newManufacturer)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("serialCode").With(value => value.Should().Be(newSerialCode)); + + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("concurrencyToken").With(value => + { + long typedValue = value.As(); + typedValue.Should().BeGreaterThan(0); + typedValue.Should().NotBe(ignoredToken); + }); + + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); + + long newDiskId = long.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Disk diskInDatabase = await dbContext.Disks.FirstWithIdAsync(newDiskId); + + diskInDatabase.Manufacturer.Should().Be(newManufacturer); + diskInDatabase.SerialCode.Should().Be(newSerialCode); + diskInDatabase.xmin.Should().BeGreaterThan(0).And.NotBe(ignoredToken); + }); + } + + [Fact(Skip = "There is no way to send the token, which is needed to find the related resource.")] + public async Task Can_create_resource_with_relationship() + { + // Arrange + Partition existingPartition = _fakers.Partition.Generate(); + + string newManufacturer = _fakers.Disk.Generate().Manufacturer; + string newSerialCode = _fakers.Disk.Generate().SerialCode; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Partitions.Add(existingPartition); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "disks", + attributes = new + { + manufacturer = newManufacturer, + serialCode = newSerialCode + }, + relationships = new + { + partitions = new + { + data = new[] + { + new + { + type = "partitions", + id = existingPartition.StringId + } + } + } + } + } + }; + + const string route = "/disks"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("disks"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("manufacturer").With(value => value.Should().Be(newManufacturer)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("serialCode").With(value => value.Should().Be(newSerialCode)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("concurrencyToken").With(value => value.As().Should().BeGreaterThan(0)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); + + long newDiskId = long.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Disk diskInDatabase = await dbContext.Disks.Include(disk => disk.Partitions).FirstWithIdAsync(newDiskId); + + diskInDatabase.Manufacturer.Should().Be(newManufacturer); + diskInDatabase.SerialCode.Should().Be(newSerialCode); + diskInDatabase.xmin.Should().BeGreaterThan(0); + + diskInDatabase.Partitions.ShouldHaveCount(1); + diskInDatabase.Partitions[0].Id.Should().Be(existingPartition.Id); + }); + } + + [Fact] + public async Task Can_update_resource_using_fresh_token() + { + // Arrange + Disk existingDisk = _fakers.Disk.Generate(); + + string newSerialCode = _fakers.Disk.Generate().SerialCode; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Disks.Add(existingDisk); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "disks", + id = existingDisk.StringId, + attributes = new + { + serialCode = newSerialCode, + concurrencyToken = existingDisk.xmin + } + } + }; + + string route = "/disks/" + existingDisk.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("disks"); + responseDocument.Data.SingleValue.Id.Should().Be(existingDisk.StringId); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("manufacturer").With(value => value.Should().Be(existingDisk.Manufacturer)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("serialCode").With(value => value.Should().Be(newSerialCode)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("concurrencyToken").With(value => value.Should().NotBe(existingDisk.xmin)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Disk diskInDatabase = await dbContext.Disks.FirstWithIdAsync(existingDisk.Id); + + diskInDatabase.Manufacturer.Should().Be(existingDisk.Manufacturer); + diskInDatabase.SerialCode.Should().Be(newSerialCode); + diskInDatabase.xmin.Should().NotBe(existingDisk.xmin); + }); + } + + [Fact] + public async Task Cannot_update_resource_using_stale_token() + { + // Arrange + Disk existingDisk = _fakers.Disk.Generate(); + + string newSerialCode = _fakers.Disk.Generate().SerialCode; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Disks.Add(existingDisk); + await dbContext.SaveChangesAsync(); + await dbContext.Database.ExecuteSqlInterpolatedAsync($"update \"Disks\" set \"Manufacturer\" = 'other' where \"Id\" = {existingDisk.Id}"); + }); + + var requestBody = new + { + data = new + { + type = "disks", + id = existingDisk.StringId, + attributes = new + { + serialCode = newSerialCode, + concurrencyToken = existingDisk.xmin + } + } + }; + + string route = "/disks/" + existingDisk.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The concurrency token is missing or does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Cannot_update_resource_without_token() + { + // Arrange + Disk existingDisk = _fakers.Disk.Generate(); + + string newSerialCode = _fakers.Disk.Generate().SerialCode; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Disks.Add(existingDisk); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "disks", + id = existingDisk.StringId, + attributes = new + { + serialCode = newSerialCode + } + } + }; + + string route = "/disks/" + existingDisk.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().StartWith("The concurrency token is missing or does not match the server version."); + error.Detail.Should().BeNull(); + } + + [Fact] + public async Task Can_update_resource_with_HasOne_relationship() + { + // Arrange + Partition existingPartition = _fakers.Partition.Generate(); + existingPartition.Owner = _fakers.Disk.Generate(); + + Disk existingDisk = _fakers.Disk.Generate(); + + ulong? newFreeSpaceInBytes = _fakers.Partition.Generate().FreeSpaceInBytes; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingPartition, existingDisk); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "partitions", + id = existingPartition.StringId, + attributes = new + { + freeSpaceInBytes = newFreeSpaceInBytes, + concurrencyToken = existingPartition.xmin + }, + relationships = new + { + owner = new + { + data = new + { + type = "disks", + id = existingDisk.StringId + } + } + } + } + }; + + string route = "/partitions/" + existingPartition.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + ulong? capacityInBytes = existingPartition.CapacityInBytes; + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("partitions"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("capacityInBytes").With(value => value.Should().Be(capacityInBytes)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("freeSpaceInBytes").With(value => value.Should().Be(newFreeSpaceInBytes)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("concurrencyToken").With(value => value.As().Should().BeGreaterThan(0)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Partition partitionInDatabase = await dbContext.Partitions.Include(partition => partition.Owner).FirstWithIdAsync(existingPartition.Id); + + partitionInDatabase.CapacityInBytes.Should().Be(capacityInBytes); + partitionInDatabase.FreeSpaceInBytes.Should().Be(newFreeSpaceInBytes); + partitionInDatabase.xmin.Should().BeGreaterThan(0); + + partitionInDatabase.Owner.ShouldNotBeNull(); + partitionInDatabase.Owner.Id.Should().Be(existingDisk.Id); + }); + } + + [Fact(Skip = "There is no way to send the token, which is needed to find the related resource.")] + public async Task Can_update_resource_with_HasMany_relationship() + { + // Arrange + Disk existingDisk = _fakers.Disk.Generate(); + existingDisk.Partitions = _fakers.Partition.Generate(1); + + Partition existingPartition = _fakers.Partition.Generate(); + + string newSerialCode = _fakers.Disk.Generate().SerialCode; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingDisk, existingPartition); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "disks", + id = existingDisk.StringId, + attributes = new + { + serialCode = newSerialCode, + concurrencyToken = existingDisk.xmin + }, + relationships = new + { + partitions = new + { + data = new[] + { + new + { + type = "partitions", + id = existingPartition.StringId + } + } + } + } + } + }; + + string route = "/disks/" + existingDisk.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("disks"); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("manufacturer").With(value => value.Should().Be(existingDisk.Manufacturer)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("serialCode").With(value => value.Should().Be(newSerialCode)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("concurrencyToken").With(value => value.As().Should().BeGreaterThan(0)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Disk diskInDatabase = await dbContext.Disks.Include(disk => disk.Partitions).FirstWithIdAsync(existingDisk.Id); + + diskInDatabase.Manufacturer.Should().Be(existingDisk.Manufacturer); + diskInDatabase.SerialCode.Should().Be(newSerialCode); + diskInDatabase.xmin.Should().BeGreaterThan(0); + + diskInDatabase.Partitions.ShouldHaveCount(1); + diskInDatabase.Partitions[0].Id.Should().Be(existingPartition.Id); + }); + } + + [Fact(Skip = "There is no way to send the token, which is needed to find the resource.")] + public async Task Can_delete_resource() + { + // Arrange + Disk existingDisk = _fakers.Disk.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Disks.Add(existingDisk); + await dbContext.SaveChangesAsync(); + }); + + string route = "/disks/" + existingDisk.StringId; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Disk diskInDatabase = await dbContext.Disks.FirstOrDefaultAsync(disk => disk.Id == existingDisk.Id); + + diskInDatabase.Should().BeNull(); + }); + } + + [Fact(Skip = "There is no way to send the token, which is needed to find the related resource.")] + public async Task Can_add_to_HasMany_relationship() + { + // Arrange + Disk existingDisk = _fakers.Disk.Generate(); + Partition existingPartition = _fakers.Partition.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingDisk, existingPartition); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "partitions", + id = existingPartition.StringId + } + } + }; + + string route = $"/disks/{existingDisk.StringId}/relationships/partitions"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Disk diskInDatabase = await dbContext.Disks.Include(disk => disk.Partitions).FirstWithIdAsync(existingDisk.Id); + + diskInDatabase.Partitions.ShouldHaveCount(1); + diskInDatabase.Partitions[0].Id.Should().Be(existingPartition.Id); + }); + } + + [Fact(Skip = "There is no way to send the token, which is needed to find the related resource.")] + public async Task Can_remove_from_HasMany_relationship() + { + // Arrange + Disk existingDisk = _fakers.Disk.Generate(); + existingDisk.Partitions = _fakers.Partition.Generate(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Disks.Add(existingDisk); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "partitions", + id = existingDisk.Partitions[1].StringId + } + } + }; + + string route = $"/disks/{existingDisk.StringId}/relationships/partitions"; + + // Act + var (httpResponse, responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Disk diskInDatabase = await dbContext.Disks.Include(disk => disk.Partitions).FirstWithIdAsync(existingDisk.Id); + + diskInDatabase.Partitions.ShouldHaveCount(1); + diskInDatabase.Partitions[0].Id.Should().Be(existingDisk.Partitions[0].Id); + }); + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/Disk.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/Disk.cs new file mode 100644 index 0000000000..f26838bcf8 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/Disk.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ConcurrencyTokens +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class Disk : Identifiable + { + [Attr] + public string Manufacturer { get; set; } = null!; + + [Attr] + public string SerialCode { get; set; } = null!; + + [ConcurrencyCheck] + [Timestamp] + [Attr(PublicName = "concurrencyToken")] + // ReSharper disable once InconsistentNaming + public uint xmin { get; set; } + + [HasMany] + public IList Partitions { get; set; } = new List(); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/DisksController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/DisksController.cs new file mode 100644 index 0000000000..3ce26063ab --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/DisksController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ConcurrencyTokens +{ + public sealed class DisksController : JsonApiController + { + public DisksController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/Partition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/Partition.cs new file mode 100644 index 0000000000..cc3428ad97 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/Partition.cs @@ -0,0 +1,29 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ConcurrencyTokens +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class Partition : Identifiable + { + [Attr] + public string MountPoint { get; set; } = null!; + + [Attr] + public string FileSystem { get; set; } = null!; + + [Attr] + public ulong CapacityInBytes { get; set; } + + [Attr] + public ulong FreeSpaceInBytes { get; set; } + + [Attr(PublicName = "concurrencyToken")] + // ReSharper disable once InconsistentNaming + public uint xmin { get; set; } + + [HasOne] + public Disk Owner { get; set; } = null!; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/PartitionsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/PartitionsController.cs new file mode 100644 index 0000000000..858d036dcf --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ConcurrencyTokens/PartitionsController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ConcurrencyTokens +{ + public sealed class PartitionsController : JsonApiController + { + public PartitionsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } +}