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)
+ {
+ }
+ }
+}