diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicCustomConstrainedOperationsControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicCustomConstrainedOperationsControllerTests.cs deleted file mode 100644 index 3cdf4f20c..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicCustomConstrainedOperationsControllerTests.cs +++ /dev/null @@ -1,215 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Controllers; - -public sealed class AtomicCustomConstrainedOperationsControllerTests - : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicCustomConstrainedOperationsControllerTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Can_create_resources_for_matching_resource_type() - { - // Arrange - string newTitle1 = _fakers.MusicTrack.GenerateOne().Title; - string newTitle2 = _fakers.MusicTrack.GenerateOne().Title; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTitle1 - } - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTitle2 - } - } - } - } - }; - - const string route = "/operations/musicTracks/create"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(2); - } - - [Fact] - public async Task Cannot_create_resource_for_inaccessible_operation() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "performers", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations/musicTracks/create"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Forbidden); - error.Title.Should().Be("The requested operation is not accessible."); - error.Detail.Should().Be("The 'add' resource operation is not accessible for resource type 'performers'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_update_resource_for_inaccessible_operation() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - attributes = new - { - } - } - } - } - }; - - const string route = "/operations/musicTracks/create"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Forbidden); - error.Title.Should().Be("The requested operation is not accessible."); - error.Detail.Should().Be("The 'update' resource operation is not accessible for resource type 'musicTracks'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_add_to_ToMany_relationship_for_inaccessible_operation() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - Performer existingPerformer = _fakers.Performer.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingTrack, existingPerformer); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - id = existingPerformer.StringId - } - } - } - } - }; - - const string route = "/operations/musicTracks/create"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Forbidden); - error.Title.Should().Be("The requested operation is not accessible."); - error.Detail.Should().Be("The 'add' relationship operation is not accessible for relationship 'performers' on resource type 'musicTracks'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicDefaultConstrainedOperationsControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicDefaultConstrainedOperationsControllerTests.cs deleted file mode 100644 index 0b60bfe78..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicDefaultConstrainedOperationsControllerTests.cs +++ /dev/null @@ -1,173 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Controllers; - -public sealed class AtomicDefaultConstrainedOperationsControllerTests - : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicDefaultConstrainedOperationsControllerTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Cannot_delete_resource_for_inaccessible_operation() - { - // Arrange - TextLanguage existingLanguage = _fakers.TextLanguage.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingLanguage); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "textLanguages", - id = existingLanguage.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Forbidden); - error.Title.Should().Be("The requested operation is not accessible."); - error.Detail.Should().Be("The 'remove' resource operation is not accessible for resource type 'textLanguages'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_change_ToMany_relationship_for_inaccessible_operations() - { - // Arrange - TextLanguage existingLanguage = _fakers.TextLanguage.GenerateOne(); - Lyric existingLyric = _fakers.Lyric.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingLanguage, existingLyric); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "textLanguages", - id = existingLanguage.StringId, - relationship = "lyrics" - }, - data = new[] - { - new - { - type = "lyrics", - id = existingLyric.StringId - } - } - }, - new - { - op = "add", - @ref = new - { - type = "textLanguages", - id = existingLanguage.StringId, - relationship = "lyrics" - }, - data = new[] - { - new - { - type = "lyrics", - id = existingLyric.StringId - } - } - }, - new - { - op = "remove", - @ref = new - { - type = "textLanguages", - id = existingLanguage.StringId, - relationship = "lyrics" - }, - data = new[] - { - new - { - type = "lyrics", - id = existingLyric.StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden); - - responseDocument.Errors.ShouldHaveCount(3); - - ErrorObject error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.Forbidden); - error1.Title.Should().Be("The requested operation is not accessible."); - error1.Detail.Should().Be("The 'update' relationship operation is not accessible for relationship 'lyrics' on resource type 'textLanguages'."); - error1.Source.ShouldNotBeNull(); - error1.Source.Pointer.Should().Be("/atomic:operations[0]"); - - ErrorObject error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.Forbidden); - error2.Title.Should().Be("The requested operation is not accessible."); - error2.Detail.Should().Be("The 'add' relationship operation is not accessible for relationship 'lyrics' on resource type 'textLanguages'."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/atomic:operations[1]"); - - ErrorObject error3 = responseDocument.Errors[2]; - error3.StatusCode.Should().Be(HttpStatusCode.Forbidden); - error3.Title.Should().Be("The requested operation is not accessible."); - error3.Detail.Should().Be("The 'remove' relationship operation is not accessible for relationship 'lyrics' on resource type 'textLanguages'."); - error3.Source.ShouldNotBeNull(); - error3.Source.Pointer.Should().Be("/atomic:operations[2]"); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs deleted file mode 100644 index 81efcf9b1..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs +++ /dev/null @@ -1,32 +0,0 @@ -using JsonApiDotNetCore.AtomicOperations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Resources; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Controllers; - -[DisableRoutingConvention] -[Route("/operations/musicTracks/create")] -public sealed class CreateMusicTrackOperationsController( - IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request, - ITargetedFields targetedFields) - : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor, request, targetedFields, OnlyCreateMusicTracksOperationFilter.Instance) -{ - private sealed class OnlyCreateMusicTracksOperationFilter : IAtomicOperationFilter - { - public static readonly OnlyCreateMusicTracksOperationFilter Instance = new(); - - private OnlyCreateMusicTracksOperationFilter() - { - } - - public bool IsEnabled(ResourceType resourceType, WriteOperationKind writeOperation) - { - return writeOperation == WriteOperationKind.CreateResource && resourceType.ClrType == typeof(MusicTrack); - } - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AssignIdToTextLanguageDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AssignIdToTextLanguageDefinition.cs deleted file mode 100644 index 730f255af..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AssignIdToTextLanguageDefinition.cs +++ /dev/null @@ -1,20 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Creating; - -[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class AssignIdToTextLanguageDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter, OperationsDbContext dbContext) - : ImplicitlyChangingTextLanguageDefinition(resourceGraph, hitCounter, dbContext) -{ - public override Task OnWritingAsync(TextLanguage resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - { - if (writeOperation == WriteOperationKind.CreateResource && resource.Id == Guid.Empty) - { - resource.Id = Guid.NewGuid(); - } - - return Task.CompletedTask; - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs deleted file mode 100644 index 2438aca27..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ /dev/null @@ -1,993 +0,0 @@ -using System.Net; -using FluentAssertions; -using FluentAssertions.Extensions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Creating; - -public sealed class AtomicCreateResourceTests : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicCreateResourceTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController(); - testContext.UseController(); - testContext.UseController(); - - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.AllowUnknownFieldsInRequestBody = false; - } - - [Fact] - public async Task Can_create_resource() - { - // Arrange - string newArtistName = _fakers.Performer.GenerateOne().ArtistName!; - DateTimeOffset newBornAt = _fakers.Performer.GenerateOne().BornAt; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "performers", - attributes = new - { - artistName = newArtistName, - bornAt = newBornAt - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(1); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("performers"); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); - resource.Attributes.ShouldContainKey("bornAt").With(value => value.Should().Be(newBornAt)); - resource.Relationships.Should().BeNull(); - }); - - int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Performer performerInDatabase = await dbContext.Performers.FirstWithIdAsync(newPerformerId); - - performerInDatabase.ArtistName.Should().Be(newArtistName); - performerInDatabase.BornAt.Should().Be(newBornAt); - }); - } - - [Fact] - public async Task Can_create_resources() - { - // Arrange - const int elementCount = 5; - - List newTracks = _fakers.MusicTrack.GenerateList(elementCount); - - var operationElements = new List(elementCount); - - for (int index = 0; index < elementCount; index++) - { - operationElements.Add(new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTracks[index].Title, - lengthInSeconds = newTracks[index].LengthInSeconds, - genre = newTracks[index].Genre, - releasedAt = newTracks[index].ReleasedAt - } - } - }); - } - - var requestBody = new - { - atomic__operations = operationElements - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(elementCount); - - for (int index = 0; index < elementCount; index++) - { - responseDocument.Results[index].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.ShouldNotBeNull(); - resource.Type.Should().Be("musicTracks"); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTracks[index].Title)); - - resource.Attributes.ShouldContainKey("lengthInSeconds") - .With(value => value.As().Should().BeApproximately(newTracks[index].LengthInSeconds)); - - resource.Attributes.ShouldContainKey("genre").With(value => value.Should().Be(newTracks[index].Genre)); - resource.Attributes.ShouldContainKey("releasedAt").With(value => value.Should().Be(newTracks[index].ReleasedAt)); - - resource.Relationships.ShouldNotBeEmpty(); - }); - } - - Guid[] newTrackIds = responseDocument.Results.Select(result => Guid.Parse(result.Data.SingleValue!.Id.ShouldNotBeNull())).ToArray(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List tracksInDatabase = await dbContext.MusicTracks.Where(musicTrack => newTrackIds.Contains(musicTrack.Id)).ToListAsync(); - - tracksInDatabase.ShouldHaveCount(elementCount); - - for (int index = 0; index < elementCount; index++) - { - MusicTrack trackInDatabase = tracksInDatabase.Single(musicTrack => musicTrack.Id == newTrackIds[index]); - - trackInDatabase.Title.Should().Be(newTracks[index].Title); - trackInDatabase.LengthInSeconds.Should().BeApproximately(newTracks[index].LengthInSeconds); - trackInDatabase.Genre.Should().Be(newTracks[index].Genre); - trackInDatabase.ReleasedAt.Should().Be(newTracks[index].ReleasedAt); - } - }); - } - - [Fact] - public async Task Can_create_resource_without_attributes_or_relationships() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "performers", - attributes = new - { - }, - relationship = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(1); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("performers"); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().BeNull()); - resource.Attributes.ShouldContainKey("bornAt").With(value => value.Should().Be(default(DateTimeOffset))); - resource.Relationships.Should().BeNull(); - }); - - int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Performer performerInDatabase = await dbContext.Performers.FirstWithIdAsync(newPerformerId); - - performerInDatabase.ArtistName.Should().BeNull(); - performerInDatabase.BornAt.Should().Be(default); - }); - } - - [Fact] - public async Task Cannot_create_resource_with_unknown_attribute() - { - // Arrange - string newName = _fakers.Playlist.GenerateOne().Name; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "playlists", - attributes = new - { - doesNotExist = "ignored", - name = newName - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown attribute found."); - error.Detail.Should().Be("Attribute 'doesNotExist' does not exist on resource type 'playlists'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/doesNotExist"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Can_create_resource_with_unknown_attribute() - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.AllowUnknownFieldsInRequestBody = true; - - string newName = _fakers.Playlist.GenerateOne().Name; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "playlists", - attributes = new - { - doesNotExist = "ignored", - name = newName - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(1); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("playlists"); - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newName)); - resource.Relationships.ShouldNotBeEmpty(); - }); - - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist performerInDatabase = await dbContext.Playlists.FirstWithIdAsync(newPlaylistId); - - performerInDatabase.Name.Should().Be(newName); - }); - } - - [Fact] - public async Task Cannot_create_resource_with_unknown_relationship() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "lyrics", - relationships = new - { - doesNotExist = new - { - data = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.Int32 - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); - error.Detail.Should().Be("Relationship 'doesNotExist' does not exist on resource type 'lyrics'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/doesNotExist"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Can_create_resource_with_unknown_relationship() - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.AllowUnknownFieldsInRequestBody = true; - - string newLyricText = _fakers.Lyric.GenerateOne().Text; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "lyrics", - attributes = new - { - text = newLyricText - }, - relationships = new - { - doesNotExist = new - { - data = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.Int32 - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(1); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("lyrics"); - resource.Attributes.ShouldNotBeEmpty(); - resource.Relationships.ShouldNotBeEmpty(); - }); - - long newLyricId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricInDatabase = await dbContext.Lyrics.FirstWithIdAsync(newLyricId); - - lyricInDatabase.ShouldNotBeNull(); - }); - } - - [Fact] - public async Task Cannot_create_resource_with_client_generated_ID() - { - // Arrange - MusicTrack newTrack = _fakers.MusicTrack.GenerateOne(); - newTrack.Id = Guid.NewGuid(); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - id = newTrack.StringId, - attributes = new - { - title = newTrack.Title - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Forbidden); - error.Title.Should().Be("Failed to deserialize request body: The use of client-generated IDs is disabled."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_resource_for_href_element() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - href = "/api/musicTracks" - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_resource_for_ref_element() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "musicTracks" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'relationship' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_resource_for_missing_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add" - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_resource_for_null_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = (object?)null - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_resource_for_array_data() - { - // Arrange - string newArtistName = _fakers.Performer.GenerateOne().ArtistName!; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new[] - { - new - { - type = "performers", - attributes = new - { - artistName = newArtistName - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of an array."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_resource_for_missing_type() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_resource_for_unknown_type() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = Unknown.ResourceType - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_resource_with_readonly_attribute() - { - // Arrange - string newPlaylistName = _fakers.Playlist.GenerateOne().Name; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "playlists", - attributes = new - { - name = newPlaylistName, - isArchived = true - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Attribute is read-only."); - error.Detail.Should().Be("Attribute 'isArchived' on resource type 'playlists' is read-only."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/isArchived"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_resource_with_incompatible_attribute_value() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "performers", - attributes = new - { - bornAt = 12345 - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Incompatible attribute value found."); - error.Detail.Should().Be("Failed to convert attribute 'bornAt' with value '12345' of type 'Number' to type 'DateTimeOffset'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/bornAt"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Can_create_resource_with_attributes_and_multiple_relationship_types() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.GenerateOne(); - RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); - Performer existingPerformer = _fakers.Performer.GenerateOne(); - - string newTitle = _fakers.MusicTrack.GenerateOne().Title; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingLyric, existingCompany, existingPerformer); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTitle - }, - relationships = new - { - lyric = new - { - data = new - { - type = "lyrics", - id = existingLyric.StringId - } - }, - ownedBy = new - { - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } - }, - performers = new - { - data = new[] - { - new - { - type = "performers", - id = existingPerformer.StringId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(1); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTitle)); - resource.Relationships.ShouldNotBeEmpty(); - }); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:wrap_after_property_in_chained_method_calls true - - MusicTrack trackInDatabase = await dbContext.MusicTracks - .Include(musicTrack => musicTrack.Lyric) - .Include(musicTrack => musicTrack.OwnedBy) - .Include(musicTrack => musicTrack.Performers) - .FirstWithIdAsync(newTrackId); - - // @formatter:wrap_after_property_in_chained_method_calls restore - // @formatter:wrap_chained_method_calls restore - - trackInDatabase.Title.Should().Be(newTitle); - - trackInDatabase.Lyric.ShouldNotBeNull(); - trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - - trackInDatabase.Performers.ShouldHaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); - }); - } - - [Fact] - public async Task Cannot_assign_attribute_with_blocked_capability() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "lyrics", - attributes = new - { - createdAt = 12.July(1980) - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Attribute value cannot be assigned when creating resource."); - error.Detail.Should().Be("The attribute 'createdAt' on resource type 'lyrics' cannot be assigned to."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/createdAt"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs deleted file mode 100644 index e20d8d45f..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ /dev/null @@ -1,506 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Creating; - -public sealed class AtomicCreateResourceWithClientGeneratedIdTests - : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicCreateResourceWithClientGeneratedIdTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController(); - - testContext.ConfigureServices(services => - { - services.AddResourceDefinition(); - - services.AddSingleton(); - }); - } - - [Theory] - [InlineData(ClientIdGenerationMode.Allowed)] - [InlineData(ClientIdGenerationMode.Required)] - public async Task Can_create_resource_with_client_generated_guid_ID_having_side_effects(ClientIdGenerationMode mode) - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.ClientIdGeneration = mode; - - TextLanguage newLanguage = _fakers.TextLanguage.GenerateOne(); - newLanguage.Id = Guid.NewGuid(); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "textLanguages", - id = newLanguage.StringId, - attributes = new - { - isoCode = newLanguage.IsoCode - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - string isoCode = $"{newLanguage.IsoCode}{ImplicitlyChangingTextLanguageDefinition.Suffix}"; - - responseDocument.Results.ShouldHaveCount(1); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("textLanguages"); - resource.Attributes.ShouldContainKey("isoCode").With(value => value.Should().Be(isoCode)); - resource.Attributes.Should().NotContainKey("isRightToLeft"); - resource.Relationships.ShouldNotBeEmpty(); - }); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - TextLanguage languageInDatabase = await dbContext.TextLanguages.FirstWithIdAsync(newLanguage.Id); - - languageInDatabase.IsoCode.Should().Be(isoCode); - }); - } - - [Theory] - [InlineData(ClientIdGenerationMode.Allowed)] - [InlineData(ClientIdGenerationMode.Required)] - public async Task Can_create_resource_with_client_generated_guid_ID_having_no_side_effects(ClientIdGenerationMode mode) - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.ClientIdGeneration = mode; - - MusicTrack newTrack = _fakers.MusicTrack.GenerateOne(); - newTrack.Id = Guid.NewGuid(); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - id = newTrack.StringId, - attributes = new - { - title = newTrack.Title, - lengthInSeconds = newTrack.LengthInSeconds, - releasedAt = newTrack.ReleasedAt - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdAsync(newTrack.Id); - - trackInDatabase.Title.Should().Be(newTrack.Title); - trackInDatabase.LengthInSeconds.Should().BeApproximately(newTrack.LengthInSeconds); - }); - } - - [Theory] - [InlineData(ClientIdGenerationMode.Allowed)] - public async Task Can_create_resource_for_missing_client_generated_ID_having_side_effects(ClientIdGenerationMode mode) - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.ClientIdGeneration = mode; - - string? newIsoCode = _fakers.TextLanguage.GenerateOne().IsoCode; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "textLanguages", - attributes = new - { - isoCode = newIsoCode - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - string isoCode = $"{newIsoCode}{ImplicitlyChangingTextLanguageDefinition.Suffix}"; - - responseDocument.Results.ShouldHaveCount(1); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("textLanguages"); - resource.Attributes.ShouldContainKey("isoCode").With(value => value.Should().Be(isoCode)); - resource.Relationships.ShouldNotBeEmpty(); - }); - - Guid newLanguageId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - TextLanguage languageInDatabase = await dbContext.TextLanguages.FirstWithIdAsync(newLanguageId); - - languageInDatabase.IsoCode.Should().Be(isoCode); - }); - } - - [Theory] - [InlineData(ClientIdGenerationMode.Required)] - public async Task Cannot_create_resource_for_missing_client_generated_ID(ClientIdGenerationMode mode) - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.ClientIdGeneration = mode; - - string? newIsoCode = _fakers.TextLanguage.GenerateOne().IsoCode; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "textLanguages", - attributes = new - { - isoCode = newIsoCode - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Theory] - [InlineData(ClientIdGenerationMode.Allowed)] - [InlineData(ClientIdGenerationMode.Required)] - public async Task Cannot_create_resource_for_existing_client_generated_ID(ClientIdGenerationMode mode) - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.ClientIdGeneration = mode; - - TextLanguage existingLanguage = _fakers.TextLanguage.GenerateOne(); - existingLanguage.Id = Guid.NewGuid(); - - TextLanguage languageToCreate = _fakers.TextLanguage.GenerateOne(); - languageToCreate.Id = existingLanguage.Id; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TextLanguages.Add(languageToCreate); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "textLanguages", - id = languageToCreate.StringId, - attributes = new - { - isoCode = languageToCreate.IsoCode - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Conflict); - error.Title.Should().Be("Another resource with the specified ID already exists."); - error.Detail.Should().Be($"Another resource of type 'textLanguages' with ID '{languageToCreate.StringId}' already exists."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.Should().NotContainKey("requestBody"); - } - - [Theory] - [InlineData(ClientIdGenerationMode.Allowed)] - [InlineData(ClientIdGenerationMode.Required)] - public async Task Cannot_create_resource_for_incompatible_ID(ClientIdGenerationMode mode) - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.ClientIdGeneration = mode; - - string guid = Unknown.StringId.Guid; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "performers", - id = guid, - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); - error.Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int32'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Theory] - [InlineData(ClientIdGenerationMode.Allowed)] - public async Task Can_create_resource_with_local_ID(ClientIdGenerationMode mode) - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.ClientIdGeneration = mode; - - string newTitle = _fakers.MusicTrack.GenerateOne().Title; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = "new-server-id", - attributes = new - { - title = newTitle - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(1); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTitle)); - resource.Relationships.Should().BeNull(); - }); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack languageInDatabase = await dbContext.MusicTracks.FirstWithIdAsync(newTrackId); - - languageInDatabase.Title.Should().Be(newTitle); - }); - } - - [Theory] - [InlineData(ClientIdGenerationMode.Required)] - public async Task Cannot_create_resource_with_local_ID(ClientIdGenerationMode mode) - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.ClientIdGeneration = mode; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "textLanguages", - lid = "new-server-id" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'lid' element cannot be used because a client-generated ID is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/lid"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Theory] - [InlineData(ClientIdGenerationMode.Allowed)] - [InlineData(ClientIdGenerationMode.Required)] - public async Task Cannot_create_resource_for_ID_and_local_ID(ClientIdGenerationMode mode) - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.ClientIdGeneration = mode; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "lyrics", - id = Unknown.StringId.For(), - lid = "local-1" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs deleted file mode 100644 index 0d4d367c1..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs +++ /dev/null @@ -1,739 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Creating; - -public sealed class AtomicCreateResourceWithToManyRelationshipTests - : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicCreateResourceWithToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController(); - testContext.UseController(); - } - - [Fact] - public async Task Can_create_OneToMany_relationship() - { - // Arrange - List existingPerformers = _fakers.Performer.GenerateList(2); - string newTitle = _fakers.MusicTrack.GenerateOne().Title; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.AddRange(existingPerformers); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTitle - }, - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "performers", - id = existingPerformers[0].StringId - }, - new - { - type = "performers", - id = existingPerformers[1].StringId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(1); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Attributes.ShouldNotBeEmpty(); - resource.Relationships.ShouldNotBeEmpty(); - }); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - - trackInDatabase.Performers.ShouldHaveCount(2); - trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); - trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); - }); - } - - [Fact] - public async Task Can_create_ManyToMany_relationship() - { - // Arrange - List existingTracks = _fakers.MusicTrack.GenerateList(3); - string newName = _fakers.Playlist.GenerateOne().Name; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.AddRange(existingTracks); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "playlists", - attributes = new - { - name = newName - }, - relationships = new - { - tracks = new - { - data = new[] - { - new - { - type = "musicTracks", - id = existingTracks[0].StringId - }, - new - { - type = "musicTracks", - id = existingTracks[1].StringId - }, - new - { - type = "musicTracks", - id = existingTracks[2].StringId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(1); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("playlists"); - resource.Attributes.ShouldNotBeEmpty(); - resource.Relationships.ShouldNotBeEmpty(); - }); - - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); - - playlistInDatabase.Tracks.ShouldHaveCount(3); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[2].Id); - }); - } - - [Fact] - public async Task Cannot_create_for_missing_relationship_type() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - performers = new - { - data = new[] - { - new - { - id = Unknown.StringId.For() - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_unknown_relationship_type() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = Unknown.ResourceType, - id = Unknown.StringId.For() - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_missing_relationship_ID() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "performers" - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_unknown_relationship_IDs() - { - // Arrange - string newTitle = _fakers.MusicTrack.GenerateOne().Title; - - string performerId1 = Unknown.StringId.For(); - string performerId2 = Unknown.StringId.AltFor(); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTitle - }, - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "performers", - id = performerId1 - }, - new - { - type = "performers", - id = performerId2 - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.ShouldHaveCount(2); - - ErrorObject error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.NotFound); - error1.Title.Should().Be("A related resource does not exist."); - error1.Detail.Should().Be($"Related resource of type 'performers' with ID '{performerId1}' in relationship 'performers' does not exist."); - error1.Source.ShouldNotBeNull(); - error1.Source.Pointer.Should().Be("/atomic:operations[0]"); - error1.Meta.Should().NotContainKey("requestBody"); - - ErrorObject error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.NotFound); - error2.Title.Should().Be("A related resource does not exist."); - error2.Detail.Should().Be($"Related resource of type 'performers' with ID '{performerId2}' in relationship 'performers' does not exist."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/atomic:operations[0]"); - error2.Meta.Should().NotContainKey("requestBody"); - } - - [Fact] - public async Task Cannot_create_on_relationship_type_mismatch() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "playlists", - id = Unknown.StringId.For() - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Conflict); - error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is not convertible to type 'performers' of relationship 'performers'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Can_create_with_duplicates() - { - // Arrange - Performer existingPerformer = _fakers.Performer.GenerateOne(); - string newTitle = _fakers.MusicTrack.GenerateOne().Title; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTitle - }, - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "performers", - id = existingPerformer.StringId - }, - new - { - type = "performers", - id = existingPerformer.StringId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(1); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Attributes.ShouldNotBeEmpty(); - resource.Relationships.ShouldNotBeEmpty(); - }); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - - trackInDatabase.Performers.ShouldHaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); - }); - } - - [Fact] - public async Task Cannot_create_with_missing_data_in_OneToMany_relationship() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - performers = new - { - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_with_null_data_in_ManyToMany_relationship() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "playlists", - relationships = new - { - tracks = new - { - data = (object?)null - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of 'null'."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/tracks/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_with_object_data_in_ManyToMany_relationship() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "playlists", - relationships = new - { - tracks = new - { - data = new - { - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of an object."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/tracks/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_assign_relationship_with_blocked_capability() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - occursIn = new - { - data = new[] - { - new - { - type = "playlists", - id = Unknown.StringId.For() - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Relationship cannot be assigned."); - error.Detail.Should().Be("The relationship 'occursIn' on resource type 'musicTracks' cannot be assigned to."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/occursIn"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs deleted file mode 100644 index a52210580..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs +++ /dev/null @@ -1,786 +0,0 @@ -using System.Net; -using System.Text.Json; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Creating; - -public sealed class AtomicCreateResourceWithToOneRelationshipTests - : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicCreateResourceWithToOneRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController(); - testContext.UseController(); - } - - [Fact] - public async Task Can_create_OneToOne_relationship_from_principal_side() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - string newLyricText = _fakers.Lyric.GenerateOne().Text; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "lyrics", - attributes = new - { - text = newLyricText - }, - relationships = new - { - track = new - { - data = new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(1); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("lyrics"); - resource.Attributes.ShouldNotBeEmpty(); - resource.Relationships.ShouldNotBeEmpty(); - }); - - long newLyricId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(newLyricId); - - lyricInDatabase.Track.ShouldNotBeNull(); - lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); - }); - } - - [Fact] - public async Task Can_create_OneToOne_relationship_from_dependent_side() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.GenerateOne(); - string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Lyrics.Add(existingLyric); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTrackTitle - }, - relationships = new - { - lyric = new - { - data = new - { - type = "lyrics", - id = existingLyric.StringId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(1); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Attributes.ShouldNotBeEmpty(); - resource.Relationships.ShouldNotBeEmpty(); - }); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(newTrackId); - - trackInDatabase.Lyric.ShouldNotBeNull(); - trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - }); - } - - [Fact] - public async Task Can_create_resources_with_ToOne_relationship() - { - // Arrange - const int elementCount = 5; - - RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); - string[] newTrackTitles = _fakers.MusicTrack.GenerateList(elementCount).Select(musicTrack => musicTrack.Title).ToArray(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var operationElements = new List(elementCount); - - for (int index = 0; index < elementCount; index++) - { - operationElements.Add(new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTrackTitles[index] - }, - relationships = new - { - ownedBy = new - { - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } - } - } - } - }); - } - - var requestBody = new - { - atomic__operations = operationElements - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(elementCount); - - for (int index = 0; index < elementCount; index++) - { - responseDocument.Results[index].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitles[index])); - }); - } - - Guid[] newTrackIds = responseDocument.Results.Select(result => Guid.Parse(result.Data.SingleValue!.Id.ShouldNotBeNull())).ToArray(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:wrap_after_property_in_chained_method_calls true - - List tracksInDatabase = await dbContext.MusicTracks - .Include(musicTrack => musicTrack.OwnedBy) - .Where(musicTrack => newTrackIds.Contains(musicTrack.Id)) - .ToListAsync(); - - // @formatter:wrap_after_property_in_chained_method_calls restore - // @formatter:wrap_chained_method_calls restore - - tracksInDatabase.ShouldHaveCount(elementCount); - - for (int index = 0; index < elementCount; index++) - { - MusicTrack trackInDatabase = tracksInDatabase.Single(musicTrack => musicTrack.Id == newTrackIds[index]); - - trackInDatabase.Title.Should().Be(newTrackTitles[index]); - - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - } - }); - } - - [Fact] - public async Task Cannot_create_for_null_relationship() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - lyric = (object?)null - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_missing_data_in_relationship() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - lyric = new - { - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_array_data_in_relationship() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - lyric = new - { - data = new[] - { - new - { - type = "lyrics", - id = Unknown.StringId.For() - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an object or 'null', instead of an array."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_missing_relationship_type() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - lyric = new - { - data = new - { - id = Unknown.StringId.For() - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_unknown_relationship_type() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - lyric = new - { - data = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.For() - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_missing_relationship_ID() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - lyric = new - { - data = new - { - type = "lyrics" - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_with_unknown_relationship_ID() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; - - string lyricId = Unknown.StringId.For(); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTrackTitle - }, - relationships = new - { - lyric = new - { - data = new - { - type = "lyrics", - id = lyricId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject 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 'lyrics' with ID '{lyricId}' in relationship 'lyric' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.Should().NotContainKey("requestBody"); - } - - [Fact] - public async Task Cannot_create_on_relationship_type_mismatch() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - lyric = new - { - data = new - { - type = "playlists", - id = Unknown.StringId.For() - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Conflict); - error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is not convertible to type 'lyrics' of relationship 'lyric'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Can_create_resource_with_duplicate_relationship() - { - // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); - string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTrackTitle - }, - relationships = new - { - ownedBy = new - { - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } - }, - ownedBy_duplicate = new - { - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } - } - } - } - } - } - }; - - string requestBodyText = JsonSerializer.Serialize(requestBody).Replace("ownedBy_duplicate", "ownedBy"); - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBodyText); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(1); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Attributes.ShouldNotBeEmpty(); - resource.Relationships.ShouldNotBeEmpty(); - }); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(newTrackId); - - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - }); - } - - [Fact] - public async Task Cannot_assign_relationship_with_blocked_capability() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "lyrics", - relationships = new - { - language = new - { - data = new - { - type = "textLanguages", - id = Unknown.StringId.For() - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Relationship cannot be assigned."); - error.Detail.Should().Be("The relationship 'language' on resource type 'lyrics' cannot be assigned to."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/language"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/DateMustBeInThePastAttribute.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/DateMustBeInThePastAttribute.cs deleted file mode 100644 index 2a76f8ef3..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/DateMustBeInThePastAttribute.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using System.Reflection; -using JsonApiDotNetCore.Resources; -using Microsoft.Extensions.DependencyInjection; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; - -[AttributeUsage(AttributeTargets.Property)] -internal sealed class DateMustBeInThePastAttribute : ValidationAttribute -{ - protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) - { - var targetedFields = validationContext.GetRequiredService(); - - if (targetedFields.Attributes.Any(attribute => attribute.Property.Name == validationContext.MemberName)) - { - PropertyInfo propertyInfo = validationContext.ObjectType.GetProperty(validationContext.MemberName!)!; - - if (propertyInfo.PropertyType == typeof(DateTimeOffset) || propertyInfo.PropertyType == typeof(DateTimeOffset?)) - { - var typedValue = (DateTimeOffset?)propertyInfo.GetValue(validationContext.ObjectInstance); - - var timeProvider = validationContext.GetRequiredService(); - DateTimeOffset utcNow = timeProvider.GetUtcNow(); - - if (typedValue >= utcNow) - { - return new ValidationResult($"{validationContext.MemberName} must be in the past."); - } - } - } - - return ValidationResult.Success; - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs deleted file mode 100644 index cbc3280ae..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs +++ /dev/null @@ -1,631 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Deleting; - -public sealed class AtomicDeleteResourceTests : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicDeleteResourceTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Can_delete_existing_resource() - { - // Arrange - Performer existingPerformer = _fakers.Performer.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "performers", - id = existingPerformer.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Performer? performerInDatabase = await dbContext.Performers.FirstWithIdOrDefaultAsync(existingPerformer.Id); - - performerInDatabase.Should().BeNull(); - }); - } - - [Fact] - public async Task Can_delete_existing_resources() - { - // Arrange - const int elementCount = 5; - - List existingTracks = _fakers.MusicTrack.GenerateList(elementCount); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.AddRange(existingTracks); - await dbContext.SaveChangesAsync(); - }); - - var operationElements = new List(elementCount); - - for (int index = 0; index < elementCount; index++) - { - operationElements.Add(new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTracks[index].StringId - } - }); - } - - var requestBody = new - { - atomic__operations = operationElements - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - - tracksInDatabase.Should().BeEmpty(); - }); - } - - [Fact] - public async Task Can_delete_resource_with_OneToOne_relationship_from_principal_side() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.GenerateOne(); - existingLyric.Track = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Lyrics.Add(existingLyric); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = existingLyric.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric? lyricInDatabase = await dbContext.Lyrics.FirstWithIdOrDefaultAsync(existingLyric.Id); - - lyricInDatabase.Should().BeNull(); - - MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdAsync(existingLyric.Track.Id); - - trackInDatabase.Lyric.Should().BeNull(); - }); - } - - [Fact] - public async Task Can_delete_resource_with_OneToOne_relationship_from_dependent_side() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - existingTrack.Lyric = _fakers.Lyric.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack? trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(existingTrack.Id); - - trackInDatabase.Should().BeNull(); - - Lyric lyricInDatabase = await dbContext.Lyrics.FirstWithIdAsync(existingTrack.Lyric.Id); - - lyricInDatabase.Track.Should().BeNull(); - }); - } - - [Fact] - public async Task Can_delete_existing_resource_with_OneToMany_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - existingTrack.Performers = _fakers.Performer.GenerateList(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack? trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(existingTrack.Id); - - trackInDatabase.Should().BeNull(); - - List performersInDatabase = await dbContext.Performers.ToListAsync(); - - performersInDatabase.Should().ContainSingle(userAccount => userAccount.Id == existingTrack.Performers.ElementAt(0).Id); - performersInDatabase.Should().ContainSingle(userAccount => userAccount.Id == existingTrack.Performers.ElementAt(1).Id); - }); - } - - [Fact] - public async Task Can_delete_existing_resource_with_ManyToMany_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.GenerateOne(); - existingPlaylist.Tracks = _fakers.MusicTrack.GenerateList(1); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Playlists.Add(existingPlaylist); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist? playlistInDatabase = await dbContext.Playlists.FirstWithIdOrDefaultAsync(existingPlaylist.Id); - - playlistInDatabase.Should().BeNull(); - - MusicTrack? trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(existingPlaylist.Tracks[0].Id); - - trackInDatabase.ShouldNotBeNull(); - }); - } - - [Fact] - public async Task Cannot_delete_resource_for_href_element() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - href = "/api/musicTracks/1" - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_delete_resource_for_missing_ref_element() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove" - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_delete_resource_for_missing_type() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - id = Unknown.StringId.Int32 - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_delete_resource_for_unknown_type() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.Int32 - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_delete_resource_for_missing_ID() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_delete_resource_for_unknown_ID() - { - // Arrange - string performerId = Unknown.StringId.For(); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "performers", - id = performerId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject 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 'performers' with ID '{performerId}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.Should().NotContainKey("requestBody"); - } - - [Fact] - public async Task Cannot_delete_resource_for_incompatible_ID() - { - // Arrange - string guid = Unknown.StringId.Guid; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "playlists", - id = guid - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); - error.Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int64'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_delete_resource_for_ID_and_local_ID() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - lid = "local-1" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ImplicitlyChangingTextLanguageDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ImplicitlyChangingTextLanguageDefinition.cs deleted file mode 100644 index fedd490dd..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ImplicitlyChangingTextLanguageDefinition.cs +++ /dev/null @@ -1,29 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; - -/// -/// Used to simulate side effects that occur in the database while saving, typically caused by database triggers. -/// -[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public class ImplicitlyChangingTextLanguageDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter, OperationsDbContext dbContext) - : HitCountingResourceDefinition(resourceGraph, hitCounter) -{ - internal const string Suffix = " (changed)"; - - private readonly OperationsDbContext _dbContext = dbContext; - - public override async Task OnWriteSucceededAsync(TextLanguage resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - { - await base.OnWriteSucceededAsync(resource, writeOperation, cancellationToken); - - if (writeOperation is not WriteOperationKind.DeleteResource) - { - string statement = $"Update \"TextLanguages\" SET \"IsoCode\" = '{resource.IsoCode}{Suffix}' WHERE \"Id\" = '{resource.StringId}'"; - await _dbContext.Database.ExecuteSqlRawAsync(statement, cancellationToken); - } - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs deleted file mode 100644 index 4686e5524..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs +++ /dev/null @@ -1,168 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Links; - -public sealed class AtomicAbsoluteLinksTests : IClassFixture, OperationsDbContext>> -{ - private const string HostPrefix = "http://localhost"; - - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicAbsoluteLinksTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController(); - testContext.UseController(); - - testContext.ConfigureServices(services => services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>))); - } - - [Fact] - public async Task Update_resource_with_side_effects_returns_absolute_links() - { - // Arrange - TextLanguage existingLanguage = _fakers.TextLanguage.GenerateOne(); - RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingLanguage, existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "update", - data = new - { - type = "textLanguages", - id = existingLanguage.StringId, - attributes = new - { - } - } - }, - new - { - op = "update", - data = new - { - type = "recordCompanies", - id = existingCompany.StringId, - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(2); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - string languageLink = $"{HostPrefix}/textLanguages/{existingLanguage.StringId}"; - - resource.ShouldNotBeNull(); - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(languageLink); - - resource.Relationships.ShouldContainKey("lyrics").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{languageLink}/relationships/lyrics"); - value.Links.Related.Should().Be($"{languageLink}/lyrics"); - }); - }); - - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - string companyLink = $"{HostPrefix}/recordCompanies/{existingCompany.StringId}"; - - resource.ShouldNotBeNull(); - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(companyLink); - - resource.Relationships.ShouldContainKey("tracks").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{companyLink}/relationships/tracks"); - value.Links.Related.Should().Be($"{companyLink}/tracks"); - }); - }); - } - - [Fact] - public async Task Update_resource_with_side_effects_and_missing_resource_controller_hides_links() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Playlists.Add(existingPlaylist); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "update", - data = new - { - type = "playlists", - id = existingPlaylist.StringId, - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(1); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.ShouldNotBeNull(); - resource.Links.Should().BeNull(); - resource.Relationships.Should().BeNull(); - }); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs deleted file mode 100644 index 86b8ad984..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreTests.Startups; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Links; - -public sealed class AtomicRelativeLinksWithNamespaceTests - : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicRelativeLinksWithNamespaceTests( - IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController(); - testContext.UseController(); - - testContext.ConfigureServices(services => services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>))); - } - - [Fact] - public async Task Create_resource_with_side_effects_returns_relative_links() - { - // Arrange - string newCompanyName = _fakers.RecordCompany.GenerateOne().Name; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "textLanguages", - attributes = new - { - } - } - }, - new - { - op = "add", - data = new - { - type = "recordCompanies", - attributes = new - { - name = newCompanyName - } - } - } - } - }; - - const string route = "/api/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(2); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull(); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - string languageLink = $"/api/textLanguages/{Guid.Parse(resource.Id.ShouldNotBeNull())}"; - - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(languageLink); - - resource.Relationships.ShouldContainKey("lyrics").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{languageLink}/relationships/lyrics"); - value.Links.Related.Should().Be($"{languageLink}/lyrics"); - }); - }); - - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull(); - - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - string companyLink = $"/api/recordCompanies/{short.Parse(resource.Id.ShouldNotBeNull())}"; - - resource.Links.ShouldNotBeNull(); - resource.Links.Self.Should().Be(companyLink); - - resource.Relationships.ShouldContainKey("tracks").With(value => - { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.Should().Be($"{companyLink}/relationships/tracks"); - value.Links.Related.Should().Be($"{companyLink}/tracks"); - }); - }); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs deleted file mode 100644 index e65b21876..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs +++ /dev/null @@ -1,2551 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.LocalIds; - -public sealed class AtomicLocalIdTests : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicLocalIdTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Can_create_resource_with_ManyToOne_relationship_using_local_ID() - { - // Arrange - RecordCompany newCompany = _fakers.RecordCompany.GenerateOne(); - string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; - - const string companyLocalId = "company-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "recordCompanies", - lid = companyLocalId, - attributes = new - { - name = newCompany.Name, - countryOfResidence = newCompany.CountryOfResidence - } - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTrackTitle - }, - relationships = new - { - ownedBy = new - { - data = new - { - type = "recordCompanies", - lid = companyLocalId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(2); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("recordCompanies"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompany.Name)); - resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(newCompany.CountryOfResidence)); - }); - - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); - - short newCompanyId = short.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(newTrackId); - - trackInDatabase.Title.Should().Be(newTrackTitle); - - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); - trackInDatabase.OwnedBy.Name.Should().Be(newCompany.Name); - trackInDatabase.OwnedBy.CountryOfResidence.Should().Be(newCompany.CountryOfResidence); - }); - } - - [Fact] - public async Task Can_create_resource_with_OneToMany_relationship_using_local_ID() - { - // Arrange - Performer newPerformer = _fakers.Performer.GenerateOne(); - string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; - - const string performerLocalId = "performer-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "performers", - lid = performerLocalId, - attributes = new - { - artistName = newPerformer.ArtistName, - bornAt = newPerformer.BornAt - } - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTrackTitle - }, - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "performers", - lid = performerLocalId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(2); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("performers"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newPerformer.ArtistName)); - resource.Attributes.ShouldContainKey("bornAt").With(value => value.Should().Be(newPerformer.BornAt)); - }); - - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); - - int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - - trackInDatabase.Title.Should().Be(newTrackTitle); - - trackInDatabase.Performers.ShouldHaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); - trackInDatabase.Performers[0].ArtistName.Should().Be(newPerformer.ArtistName); - trackInDatabase.Performers[0].BornAt.Should().Be(newPerformer.BornAt); - }); - } - - [Fact] - public async Task Can_create_resource_with_ManyToMany_relationship_using_local_ID() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; - string newPlaylistName = _fakers.Playlist.GenerateOne().Name; - - const string trackLocalId = "track-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } - } - }, - new - { - op = "add", - data = new - { - type = "playlists", - attributes = new - { - name = newPlaylistName - }, - relationships = new - { - tracks = new - { - data = new[] - { - new - { - type = "musicTracks", - lid = trackLocalId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(2); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); - - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("playlists"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newPlaylistName)); - }); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - long newPlaylistId = long.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); - - playlistInDatabase.Name.Should().Be(newPlaylistName); - - playlistInDatabase.Tracks.ShouldHaveCount(1); - playlistInDatabase.Tracks[0].Id.Should().Be(newTrackId); - playlistInDatabase.Tracks[0].Title.Should().Be(newTrackTitle); - }); - } - - [Fact] - public async Task Cannot_consume_local_ID_that_is_assigned_in_same_operation() - { - // Arrange - const string companyLocalId = "company-1"; - - string newCompanyName = _fakers.RecordCompany.GenerateOne().Name; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new - { - op = "add", - data = new - { - type = "recordCompanies", - lid = companyLocalId, - attributes = new - { - name = newCompanyName - }, - relationships = new - { - parent = new - { - data = new - { - type = "recordCompanies", - lid = companyLocalId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Local ID cannot be both defined and used within the same operation."); - error.Detail.Should().Be("Local ID 'company-1' cannot be both defined and used within the same operation."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); - } - - [Fact] - public async Task Cannot_reassign_local_ID() - { - // Arrange - string newPlaylistName = _fakers.Playlist.GenerateOne().Name; - const string playlistLocalId = "playlist-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new - { - op = "add", - data = new - { - type = "playlists", - lid = playlistLocalId, - attributes = new - { - name = newPlaylistName - } - } - }, - new - { - op = "add", - data = new - { - type = "playlists", - lid = playlistLocalId, - attributes = new - { - name = newPlaylistName - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Another local ID with the same name is already defined at this point."); - error.Detail.Should().Be("Another local ID with name 'playlist-1' is already defined at this point."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[2]"); - } - - [Fact] - public async Task Can_update_resource_using_local_ID() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; - string newTrackGenre = _fakers.MusicTrack.GenerateOne().Genre!; - - const string trackLocalId = "track-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } - } - }, - new - { - op = "update", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - genre = newTrackGenre - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(2); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - resource.Attributes.ShouldContainKey("genre").With(value => value.Should().BeNull()); - }); - - responseDocument.Results[1].Data.Value.Should().BeNull(); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdAsync(newTrackId); - - trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.Genre.Should().Be(newTrackGenre); - }); - } - - [Fact] - public async Task Can_update_resource_with_relationships_using_local_ID() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; - string newArtistName = _fakers.Performer.GenerateOne().ArtistName!; - string newCompanyName = _fakers.RecordCompany.GenerateOne().Name; - - const string trackLocalId = "track-1"; - const string performerLocalId = "performer-1"; - const string companyLocalId = "company-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } - } - }, - new - { - op = "add", - data = new - { - type = "performers", - lid = performerLocalId, - attributes = new - { - artistName = newArtistName - } - } - }, - new - { - op = "add", - data = new - { - type = "recordCompanies", - lid = companyLocalId, - attributes = new - { - name = newCompanyName - } - } - }, - new - { - op = "update", - data = new - { - type = "musicTracks", - lid = trackLocalId, - relationships = new - { - ownedBy = new - { - data = new - { - type = "recordCompanies", - lid = companyLocalId - } - }, - performers = new - { - data = new[] - { - new - { - type = "performers", - lid = performerLocalId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(4); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); - - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("performers"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); - }); - - responseDocument.Results[2].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("recordCompanies"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompanyName)); - }); - - responseDocument.Results[3].Data.Value.Should().BeNull(); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); - short newCompanyId = short.Parse(responseDocument.Results[2].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:wrap_after_property_in_chained_method_calls true - - MusicTrack trackInDatabase = await dbContext.MusicTracks - .Include(musicTrack => musicTrack.OwnedBy) - .Include(musicTrack => musicTrack.Performers) - .FirstWithIdAsync(newTrackId); - - // @formatter:wrap_after_property_in_chained_method_calls restore - // @formatter:wrap_chained_method_calls restore - - trackInDatabase.Title.Should().Be(newTrackTitle); - - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); - - trackInDatabase.Performers.ShouldHaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); - trackInDatabase.Performers[0].ArtistName.Should().Be(newArtistName); - }); - } - - [Fact] - public async Task Can_create_ManyToOne_relationship_using_local_ID() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; - string newCompanyName = _fakers.RecordCompany.GenerateOne().Name; - - const string trackLocalId = "track-1"; - const string companyLocalId = "company-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } - } - }, - new - { - op = "add", - data = new - { - type = "recordCompanies", - lid = companyLocalId, - attributes = new - { - name = newCompanyName - } - } - }, - new - { - op = "update", - @ref = new - { - type = "musicTracks", - lid = trackLocalId, - relationship = "ownedBy" - }, - data = new - { - type = "recordCompanies", - lid = companyLocalId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(3); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); - - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("recordCompanies"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompanyName)); - }); - - responseDocument.Results[2].Data.Value.Should().BeNull(); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - short newCompanyId = short.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(newTrackId); - - trackInDatabase.Title.Should().Be(newTrackTitle); - - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); - trackInDatabase.OwnedBy.Name.Should().Be(newCompanyName); - }); - } - - [Fact] - public async Task Can_create_OneToMany_relationship_using_local_ID() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; - string newArtistName = _fakers.Performer.GenerateOne().ArtistName!; - - const string trackLocalId = "track-1"; - const string performerLocalId = "performer-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } - } - }, - new - { - op = "add", - data = new - { - type = "performers", - lid = performerLocalId, - attributes = new - { - artistName = newArtistName - } - } - }, - new - { - op = "update", - @ref = new - { - type = "musicTracks", - lid = trackLocalId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - lid = performerLocalId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(3); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); - - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("performers"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); - }); - - responseDocument.Results[2].Data.Value.Should().BeNull(); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - - trackInDatabase.Title.Should().Be(newTrackTitle); - - trackInDatabase.Performers.ShouldHaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); - trackInDatabase.Performers[0].ArtistName.Should().Be(newArtistName); - }); - } - - [Fact] - public async Task Can_create_ManyToMany_relationship_using_local_ID() - { - // Arrange - string newPlaylistName = _fakers.Playlist.GenerateOne().Name; - string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; - - const string playlistLocalId = "playlist-1"; - const string trackLocalId = "track-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "playlists", - lid = playlistLocalId, - attributes = new - { - name = newPlaylistName - } - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } - } - }, - new - { - op = "update", - @ref = new - { - type = "playlists", - lid = playlistLocalId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - lid = trackLocalId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(3); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("playlists"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newPlaylistName)); - }); - - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); - - responseDocument.Results[2].Data.Value.Should().BeNull(); - - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); - - playlistInDatabase.Name.Should().Be(newPlaylistName); - - playlistInDatabase.Tracks.ShouldHaveCount(1); - playlistInDatabase.Tracks[0].Id.Should().Be(newTrackId); - playlistInDatabase.Tracks[0].Title.Should().Be(newTrackTitle); - }); - } - - [Fact] - public async Task Can_replace_OneToMany_relationship_using_local_ID() - { - // Arrange - Performer existingPerformer = _fakers.Performer.GenerateOne(); - - string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; - string newArtistName = _fakers.Performer.GenerateOne().ArtistName!; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); - - const string trackLocalId = "track-1"; - const string performerLocalId = "performer-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - }, - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "performers", - id = existingPerformer.StringId - } - } - } - } - } - }, - new - { - op = "add", - data = new - { - type = "performers", - lid = performerLocalId, - attributes = new - { - artistName = newArtistName - } - } - }, - new - { - op = "update", - @ref = new - { - type = "musicTracks", - lid = trackLocalId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - lid = performerLocalId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(3); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); - - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("performers"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); - }); - - responseDocument.Results[2].Data.Value.Should().BeNull(); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - - trackInDatabase.Title.Should().Be(newTrackTitle); - - trackInDatabase.Performers.ShouldHaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); - trackInDatabase.Performers[0].ArtistName.Should().Be(newArtistName); - }); - } - - [Fact] - public async Task Can_replace_ManyToMany_relationship_using_local_ID() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - string newPlaylistName = _fakers.Playlist.GenerateOne().Name; - string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - const string playlistLocalId = "playlist-1"; - const string trackLocalId = "track-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "playlists", - lid = playlistLocalId, - attributes = new - { - name = newPlaylistName - }, - relationships = new - { - tracks = new - { - data = new[] - { - new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - } - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } - } - }, - new - { - op = "update", - @ref = new - { - type = "playlists", - lid = playlistLocalId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - lid = trackLocalId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(3); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("playlists"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newPlaylistName)); - }); - - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); - - responseDocument.Results[2].Data.Value.Should().BeNull(); - - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); - - playlistInDatabase.Name.Should().Be(newPlaylistName); - - playlistInDatabase.Tracks.ShouldHaveCount(1); - playlistInDatabase.Tracks[0].Id.Should().Be(newTrackId); - playlistInDatabase.Tracks[0].Title.Should().Be(newTrackTitle); - }); - } - - [Fact] - public async Task Can_add_to_OneToMany_relationship_using_local_ID() - { - // Arrange - Performer existingPerformer = _fakers.Performer.GenerateOne(); - - string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; - string newArtistName = _fakers.Performer.GenerateOne().ArtistName!; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); - - const string trackLocalId = "track-1"; - const string performerLocalId = "performer-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - }, - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "performers", - id = existingPerformer.StringId - } - } - } - } - } - }, - new - { - op = "add", - data = new - { - type = "performers", - lid = performerLocalId, - attributes = new - { - artistName = newArtistName - } - } - }, - new - { - op = "add", - @ref = new - { - type = "musicTracks", - lid = trackLocalId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - lid = performerLocalId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(3); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); - - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("performers"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); - }); - - responseDocument.Results[2].Data.Value.Should().BeNull(); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - - trackInDatabase.Title.Should().Be(newTrackTitle); - - trackInDatabase.Performers.ShouldHaveCount(2); - - trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); - trackInDatabase.Performers[0].ArtistName.Should().Be(existingPerformer.ArtistName); - - trackInDatabase.Performers[1].Id.Should().Be(newPerformerId); - trackInDatabase.Performers[1].ArtistName.Should().Be(newArtistName); - }); - } - - [Fact] - public async Task Can_add_to_ManyToMany_relationship_using_local_ID() - { - // Arrange - List existingTracks = _fakers.MusicTrack.GenerateList(2); - - string newPlaylistName = _fakers.Playlist.GenerateOne().Name; - string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; - - const string playlistLocalId = "playlist-1"; - const string trackLocalId = "track-1"; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.AddRange(existingTracks); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "playlists", - lid = playlistLocalId, - attributes = new - { - name = newPlaylistName - }, - relationships = new - { - tracks = new - { - data = new[] - { - new - { - type = "musicTracks", - id = existingTracks[0].StringId - } - } - } - } - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } - } - }, - new - { - op = "add", - @ref = new - { - type = "playlists", - lid = playlistLocalId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - lid = trackLocalId - } - } - }, - new - { - op = "add", - @ref = new - { - type = "playlists", - lid = playlistLocalId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingTracks[1].StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(4); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("playlists"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newPlaylistName)); - }); - - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); - - responseDocument.Results[2].Data.Value.Should().BeNull(); - - responseDocument.Results[3].Data.Value.Should().BeNull(); - - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); - - playlistInDatabase.Name.Should().Be(newPlaylistName); - - playlistInDatabase.Tracks.ShouldHaveCount(3); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == newTrackId); - }); - } - - [Fact] - public async Task Can_remove_from_OneToMany_relationship_using_local_ID() - { - // Arrange - Performer existingPerformer = _fakers.Performer.GenerateOne(); - - string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; - string newArtistName1 = _fakers.Performer.GenerateOne().ArtistName!; - string newArtistName2 = _fakers.Performer.GenerateOne().ArtistName!; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); - - const string trackLocalId = "track-1"; - const string performerLocalId1 = "performer-1"; - const string performerLocalId2 = "performer-2"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "performers", - lid = performerLocalId1, - attributes = new - { - artistName = newArtistName1 - } - } - }, - new - { - op = "add", - data = new - { - type = "performers", - lid = performerLocalId2, - attributes = new - { - artistName = newArtistName2 - } - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - }, - relationships = new - { - performers = new - { - data = new object[] - { - new - { - type = "performers", - id = existingPerformer.StringId - }, - new - { - type = "performers", - lid = performerLocalId1 - }, - new - { - type = "performers", - lid = performerLocalId2 - } - } - } - } - } - }, - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - lid = trackLocalId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - lid = performerLocalId1 - }, - new - { - type = "performers", - lid = performerLocalId2 - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(4); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("performers"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName1)); - }); - - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("performers"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName2)); - }); - - responseDocument.Results[2].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); - - responseDocument.Results[3].Data.Value.Should().BeNull(); - - Guid newTrackId = Guid.Parse(responseDocument.Results[2].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - - trackInDatabase.Title.Should().Be(newTrackTitle); - - trackInDatabase.Performers.ShouldHaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); - trackInDatabase.Performers[0].ArtistName.Should().Be(existingPerformer.ArtistName); - }); - } - - [Fact] - public async Task Can_remove_from_ManyToMany_relationship_using_local_ID() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.GenerateOne(); - existingPlaylist.Tracks = _fakers.MusicTrack.GenerateList(2); - - string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; - - const string trackLocalId = "track-1"; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Playlists.Add(existingPlaylist); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } - } - }, - new - { - op = "add", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - lid = trackLocalId - } - } - }, - new - { - op = "remove", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingPlaylist.Tracks[1].StringId - } - } - }, - new - { - op = "remove", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - lid = trackLocalId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(4); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); - - responseDocument.Results[1].Data.Value.Should().BeNull(); - - responseDocument.Results[2].Data.Value.Should().BeNull(); - - responseDocument.Results[3].Data.Value.Should().BeNull(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - - playlistInDatabase.Tracks.ShouldHaveCount(1); - playlistInDatabase.Tracks[0].Id.Should().Be(existingPlaylist.Tracks[0].Id); - }); - } - - [Fact] - public async Task Can_delete_resource_using_local_ID() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; - - const string trackLocalId = "track-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } - } - }, - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - lid = trackLocalId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(2); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("musicTracks"); - resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - }); - - responseDocument.Results[1].Data.Value.Should().BeNull(); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack? trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(newTrackId); - - trackInDatabase.Should().BeNull(); - }); - } - - [Fact] - public async Task Cannot_consume_unassigned_local_ID_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - lid = Unknown.LocalId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Server-generated value for local ID is not available at this point."); - error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); - } - - [Fact] - public async Task Cannot_consume_unassigned_local_ID_in_data_element() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new - { - op = "update", - data = new - { - type = "musicTracks", - lid = Unknown.LocalId, - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Server-generated value for local ID is not available at this point."); - error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); - } - - [Fact] - public async Task Cannot_consume_unassigned_local_ID_in_data_array() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - lid = Unknown.LocalId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Server-generated value for local ID is not available at this point."); - error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); - } - - [Fact] - public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_element() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTrackTitle - }, - relationships = new - { - ownedBy = new - { - data = new - { - type = "recordCompanies", - lid = Unknown.LocalId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Server-generated value for local ID is not available at this point."); - error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); - } - - [Fact] - public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_array() - { - // Arrange - string newPlaylistName = _fakers.Playlist.GenerateOne().Name; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new - { - op = "add", - data = new - { - type = "playlists", - attributes = new - { - name = newPlaylistName - }, - relationships = new - { - tracks = new - { - data = new[] - { - new - { - type = "musicTracks", - lid = Unknown.LocalId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Server-generated value for local ID is not available at this point."); - error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); - } - - [Fact] - public async Task Cannot_consume_local_ID_of_different_type_in_same_operation() - { - // Arrange - const string trackLocalId = "track-1"; - string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - }, - relationships = new - { - ownedBy = new - { - data = new - { - type = "recordCompanies", - lid = trackLocalId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Incompatible type in Local ID usage."); - error.Detail.Should().Be("Local ID 'track-1' belongs to resource type 'musicTracks' instead of 'recordCompanies'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); - } - - [Fact] - public async Task Cannot_consume_local_ID_of_different_type_in_ref() - { - // Arrange - const string companyLocalId = "company-1"; - - string newCompanyName = _fakers.RecordCompany.GenerateOne().Name; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new - { - op = "add", - data = new - { - type = "recordCompanies", - lid = companyLocalId, - attributes = new - { - name = newCompanyName - } - } - }, - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - lid = companyLocalId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Incompatible type in Local ID usage."); - error.Detail.Should().Be("Local ID 'company-1' belongs to resource type 'recordCompanies' instead of 'musicTracks'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[2]"); - } - - [Fact] - public async Task Cannot_consume_local_ID_of_different_type_in_data_element() - { - // Arrange - const string performerLocalId = "performer-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new - { - op = "add", - data = new - { - type = "performers", - lid = performerLocalId - } - }, - new - { - op = "update", - data = new - { - type = "playlists", - lid = performerLocalId, - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Incompatible type in Local ID usage."); - error.Detail.Should().Be("Local ID 'performer-1' belongs to resource type 'performers' instead of 'playlists'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[2]"); - } - - [Fact] - public async Task Cannot_consume_local_ID_of_different_type_in_data_array() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - const string companyLocalId = "company-1"; - - string newCompanyName = _fakers.RecordCompany.GenerateOne().Name; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new - { - op = "add", - data = new - { - type = "recordCompanies", - lid = companyLocalId, - attributes = new - { - name = newCompanyName - } - } - }, - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - lid = companyLocalId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Incompatible type in Local ID usage."); - error.Detail.Should().Be("Local ID 'company-1' belongs to resource type 'recordCompanies' instead of 'performers'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[2]"); - } - - [Fact] - public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data_element() - { - // Arrange - string newPlaylistName = _fakers.Playlist.GenerateOne().Name; - string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; - - const string playlistLocalId = "playlist-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new - { - op = "add", - data = new - { - type = "playlists", - lid = playlistLocalId, - attributes = new - { - name = newPlaylistName - } - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTrackTitle - }, - relationships = new - { - ownedBy = new - { - data = new - { - type = "recordCompanies", - lid = playlistLocalId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Incompatible type in Local ID usage."); - error.Detail.Should().Be("Local ID 'playlist-1' belongs to resource type 'playlists' instead of 'recordCompanies'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[2]"); - } - - [Fact] - public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data_array() - { - // Arrange - const string performerLocalId = "performer-1"; - string newPlaylistName = _fakers.Playlist.GenerateOne().Name; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For() - } - }, - new - { - op = "add", - data = new - { - type = "performers", - lid = performerLocalId - } - }, - new - { - op = "add", - data = new - { - type = "playlists", - attributes = new - { - name = newPlaylistName - }, - relationships = new - { - tracks = new - { - data = new[] - { - new - { - type = "musicTracks", - lid = performerLocalId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Incompatible type in Local ID usage."); - error.Detail.Should().Be("Local ID 'performer-1' belongs to resource type 'performers' instead of 'musicTracks'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[2]"); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Lyric.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Lyric.cs deleted file mode 100644 index af1ac9e18..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Lyric.cs +++ /dev/null @@ -1,25 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; - -[UsedImplicitly(ImplicitUseTargetFlags.Members)] -[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations")] -public sealed class Lyric : Identifiable -{ - [Attr] - public string? Format { get; set; } - - [Attr] - public string Text { get; set; } = null!; - - [Attr(Capabilities = AttrCapabilities.None)] - public DateTimeOffset CreatedAt { get; set; } - - [HasOne(Capabilities = HasOneCapabilities.All & ~HasOneCapabilities.AllowSet)] - public TextLanguage? Language { get; set; } - - [HasOne] - public MusicTrack? Track { get; set; } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs deleted file mode 100644 index 4d4982f73..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs +++ /dev/null @@ -1,176 +0,0 @@ -using System.Net; -using System.Text.Json; -using FluentAssertions; -using FluentAssertions.Extensions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Meta; - -public sealed class AtomicResourceMetaTests : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicResourceMetaTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - testContext.ConfigureServices(services => - { - services.AddResourceDefinition(); - services.AddResourceDefinition(); - - services.AddSingleton(); - }); - - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - hitCounter.Reset(); - } - - [Fact] - public async Task Returns_resource_meta_in_create_resource_with_side_effects() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - string newTitle1 = _fakers.MusicTrack.GenerateOne().Title; - string newTitle2 = _fakers.MusicTrack.GenerateOne().Title; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTitle1, - releasedAt = 1.January(2018).AsUtc() - } - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTitle2, - releasedAt = 23.August(1994).AsUtc() - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(2); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Meta.ShouldHaveCount(1); - - resource.Meta.ShouldContainKey("copyright").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetString().Should().Be("(C) 2018. All rights reserved."); - }); - }); - - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Meta.ShouldHaveCount(1); - - resource.Meta.ShouldContainKey("copyright").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetString().Should().Be("(C) 1994. All rights reserved."); - }); - }); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(MusicTrack), ResourceDefinitionExtensibilityPoints.GetMeta), - (typeof(MusicTrack), ResourceDefinitionExtensibilityPoints.GetMeta) - }, options => options.WithStrictOrdering()); - } - - [Fact] - public async Task Returns_resource_meta_in_update_resource_with_side_effects() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - TextLanguage existingLanguage = _fakers.TextLanguage.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TextLanguages.Add(existingLanguage); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "textLanguages", - id = existingLanguage.StringId, - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(1); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Meta.ShouldHaveCount(1); - - resource.Meta.ShouldContainKey("notice").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetString().Should().Be(TextLanguageMetaDefinition.NoticeText); - }); - }); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(TextLanguage), ResourceDefinitionExtensibilityPoints.GetMeta) - }, options => options.WithStrictOrdering()); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMeta.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMeta.cs deleted file mode 100644 index 1dec13034..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMeta.cs +++ /dev/null @@ -1,22 +0,0 @@ -using JsonApiDotNetCore.Serialization.Response; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Meta; - -public sealed class AtomicResponseMeta : IResponseMeta -{ - public IDictionary GetMeta() - { - return new Dictionary - { - ["license"] = "MIT", - ["projectUrl"] = "https://github.com/json-api-dotnet/JsonApiDotNetCore/", - ["versions"] = new[] - { - "v4.0.0", - "v3.1.0", - "v2.5.2", - "v1.3.1" - } - }; - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs deleted file mode 100644 index 88e7115f6..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs +++ /dev/null @@ -1,155 +0,0 @@ -using System.Net; -using System.Text.Json; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCore.Serialization.Response; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Meta; - -public sealed class AtomicResponseMetaTests : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicResponseMetaTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - testContext.ConfigureServices(services => - { - services.AddResourceDefinition(); - - services.AddSingleton(); - services.AddSingleton(); - }); - } - - [Fact] - public async Task Returns_top_level_meta_in_create_resource_with_side_effects() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "performers", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Meta.ShouldHaveCount(3); - - responseDocument.Meta.ShouldContainKey("license").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetString().Should().Be("MIT"); - }); - - responseDocument.Meta.ShouldContainKey("projectUrl").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetString().Should().Be("https://github.com/json-api-dotnet/JsonApiDotNetCore/"); - }); - - responseDocument.Meta.ShouldContainKey("versions").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - string?[] versionArray = element.EnumerateArray().Select(arrayItem => arrayItem.GetString()).ToArray(); - - versionArray.ShouldHaveCount(4); - versionArray.Should().Contain("v4.0.0"); - versionArray.Should().Contain("v3.1.0"); - versionArray.Should().Contain("v2.5.2"); - versionArray.Should().Contain("v1.3.1"); - }); - } - - [Fact] - public async Task Returns_top_level_meta_in_update_resource_with_side_effects() - { - // Arrange - TextLanguage existingLanguage = _fakers.TextLanguage.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TextLanguages.Add(existingLanguage); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "textLanguages", - id = existingLanguage.StringId, - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Meta.ShouldHaveCount(3); - - responseDocument.Meta.ShouldContainKey("license").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetString().Should().Be("MIT"); - }); - - responseDocument.Meta.ShouldContainKey("projectUrl").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetString().Should().Be("https://github.com/json-api-dotnet/JsonApiDotNetCore/"); - }); - - responseDocument.Meta.ShouldContainKey("versions").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - string?[] versionArray = element.EnumerateArray().Select(arrayItem => arrayItem.GetString()).ToArray(); - - versionArray.ShouldHaveCount(4); - versionArray.Should().Contain("v4.0.0"); - versionArray.Should().Contain("v3.1.0"); - versionArray.Should().Contain("v2.5.2"); - versionArray.Should().Contain("v1.3.1"); - }); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs deleted file mode 100644 index 3c8dda12a..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs +++ /dev/null @@ -1,21 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Meta; - -[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class MusicTrackMetaDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) - : HitCountingResourceDefinition(resourceGraph, hitCounter) -{ - protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.GetMeta; - - public override IDictionary GetMeta(MusicTrack resource) - { - base.GetMeta(resource); - - return new Dictionary - { - ["Copyright"] = $"(C) {resource.ReleasedAt.Year}. All rights reserved." - }; - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs deleted file mode 100644 index 679a3b76a..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs +++ /dev/null @@ -1,23 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Meta; - -[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class TextLanguageMetaDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter, OperationsDbContext dbContext) - : ImplicitlyChangingTextLanguageDefinition(resourceGraph, hitCounter, dbContext) -{ - internal const string NoticeText = "See https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes for ISO 639-1 language codes."; - - protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.GetMeta; - - public override IDictionary GetMeta(TextLanguage resource) - { - base.GetMeta(resource); - - return new Dictionary - { - ["Notice"] = NoticeText - }; - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs deleted file mode 100644 index 34618f95f..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs +++ /dev/null @@ -1,169 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.AtomicOperations; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Mixed; - -public sealed class AtomicLoggingTests : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - - public AtomicLoggingTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - testContext.ConfigureLogging(options => - { - var loggerProvider = new CapturingLoggerProvider(LogLevel.Information); - options.AddProvider(loggerProvider); - options.SetMinimumLevel(LogLevel.Information); - - options.Services.AddSingleton(loggerProvider); - }); - - testContext.ConfigureServices(services => services.AddSingleton()); - } - - [Fact] - public async Task Logs_unhandled_exception_at_Error_level() - { - // Arrange - var loggerProvider = _testContext.Factory.Services.GetRequiredService(); - loggerProvider.Clear(); - - var transactionFactory = (ThrowingOperationsTransactionFactory)_testContext.Factory.Services.GetRequiredService(); - transactionFactory.ThrowOnOperationStart = true; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "performers", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.InternalServerError); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.InternalServerError); - error.Title.Should().Be("An unhandled error occurred while processing an operation in this request."); - error.Detail.Should().Be("Simulated failure."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - - IReadOnlyList logMessages = loggerProvider.GetMessages(); - - logMessages.Should().ContainSingle(message => - message.LogLevel == LogLevel.Error && message.Text.Contains("Simulated failure.", StringComparison.Ordinal)); - } - - [Fact] - public async Task Logs_invalid_request_body_error_at_Information_level() - { - // Arrange - var loggerProvider = _testContext.Factory.Services.GetRequiredService(); - loggerProvider.Clear(); - - var transactionFactory = (ThrowingOperationsTransactionFactory)_testContext.Factory.Services.GetRequiredService(); - transactionFactory.ThrowOnOperationStart = false; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update" - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - IReadOnlyList logMessages = loggerProvider.GetMessages(); - - logMessages.Should().ContainSingle(message => message.LogLevel == LogLevel.Information && - message.Text.Contains("Failed to deserialize request body", StringComparison.Ordinal)); - } - - private sealed class ThrowingOperationsTransactionFactory : IOperationsTransactionFactory - { - public bool ThrowOnOperationStart { get; set; } - - public Task BeginTransactionAsync(CancellationToken cancellationToken) - { - IOperationsTransaction transaction = new ThrowingOperationsTransaction(this); - return Task.FromResult(transaction); - } - - private sealed class ThrowingOperationsTransaction(ThrowingOperationsTransactionFactory owner) : IOperationsTransaction - { - private readonly ThrowingOperationsTransactionFactory _owner = owner; - - public string TransactionId => "some"; - - public ValueTask DisposeAsync() - { - return ValueTask.CompletedTask; - } - - public Task BeforeProcessOperationAsync(CancellationToken cancellationToken) - { - return ThrowIfEnabled(); - } - - public Task AfterProcessOperationAsync(CancellationToken cancellationToken) - { - return ThrowIfEnabled(); - } - - public Task CommitAsync(CancellationToken cancellationToken) - { - return ThrowIfEnabled(); - } - - private Task ThrowIfEnabled() - { - if (_owner.ThrowOnOperationStart) - { - throw new InvalidOperationException("Simulated failure."); - } - - return Task.CompletedTask; - } - } - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs deleted file mode 100644 index bedb5d7da..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs +++ /dev/null @@ -1,219 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Serialization.Objects; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Mixed; - -public sealed class AtomicRequestBodyTests : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - - public AtomicRequestBodyTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Cannot_process_for_missing_request_body() - { - // Arrange - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, null!); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - - httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.AtomicOperations.ToString()); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Failed to deserialize request body: Missing request body."); - error.Detail.Should().BeNull(); - error.Source.Should().BeNull(); - error.Meta.Should().NotContainKey("requestBody"); - } - - [Fact] - public async Task Cannot_process_for_null_request_body() - { - // Arrange - const string requestBody = "null"; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); - error.Detail.Should().BeNull(); - error.Source.Should().BeNull(); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_process_for_broken_JSON_request_body() - { - // Arrange - const string requestBody = "{\"atomic__operations\":[{\"op\":"; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); - error.Detail.Should().Match("* There is an open JSON object or array that should be closed. *"); - error.Source.Should().BeNull(); - } - - [Fact] - public async Task Cannot_process_for_missing_operations_array() - { - // Arrange - const string route = "/operations"; - - var requestBody = new - { - meta = new - { - key = "value" - } - }; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: No operations found."); - error.Detail.Should().BeNull(); - error.Source.Should().BeNull(); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_process_empty_operations_array() - { - // Arrange - var requestBody = new - { - atomic__operations = Array.Empty() - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: No operations found."); - error.Detail.Should().BeNull(); - error.Source.Should().BeNull(); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_process_null_operation() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - (object?)null - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_process_for_unknown_operation_code() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "merge", - data = new - { - type = "performers", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); - error.Detail.Should().StartWith("The JSON value could not be converted to "); - error.Source.Should().BeNull(); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs deleted file mode 100644 index 0d0a54bc5..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs +++ /dev/null @@ -1,187 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Mixed; - -public sealed class AtomicSerializationTests : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicSerializationTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController(); - - testContext.ConfigureServices(services => - { - services.AddResourceDefinition(); - - services.AddSingleton(); - }); - - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.IncludeExceptionStackTraceInErrors = false; - options.IncludeJsonApiVersion = true; - options.ClientIdGeneration = ClientIdGenerationMode.Allowed; - } - - [Fact] - public async Task Hides_data_for_void_operation() - { - // Arrange - Performer existingPerformer = _fakers.Performer.GenerateOne(); - - TextLanguage newLanguage = _fakers.TextLanguage.GenerateOne(); - newLanguage.Id = Guid.NewGuid(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "update", - data = new - { - type = "performers", - id = existingPerformer.StringId, - attributes = new - { - } - } - }, - new - { - op = "add", - data = new - { - type = "textLanguages", - id = newLanguage.StringId, - attributes = new - { - isoCode = newLanguage.IsoCode - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Should().BeJson($$""" - { - "jsonapi": { - "version": "1.1", - "ext": [ - "https://jsonapi.org/ext/atomic" - ] - }, - "links": { - "self": "http://localhost/operations" - }, - "atomic:results": [ - {}, - { - "data": { - "type": "textLanguages", - "id": "{{newLanguage.StringId}}", - "attributes": { - "isoCode": "{{newLanguage.IsoCode}} (changed)" - }, - "relationships": { - "lyrics": { - "links": { - "self": "http://localhost/textLanguages/{{newLanguage.StringId}}/relationships/lyrics", - "related": "http://localhost/textLanguages/{{newLanguage.StringId}}/lyrics" - } - } - }, - "links": { - "self": "http://localhost/textLanguages/{{newLanguage.StringId}}" - } - } - } - ] - } - """); - } - - [Fact] - public async Task Includes_version_with_ext_on_error_at_operations_endpoint() - { - // Arrange - string musicTrackId = Unknown.StringId.For(); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = musicTrackId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - - string errorId = JsonApiStringConverter.ExtractErrorId(responseDocument); - - responseDocument.Should().BeJson($$""" - { - "jsonapi": { - "version": "1.1", - "ext": [ - "https://jsonapi.org/ext/atomic" - ] - }, - "links": { - "self": "http://localhost/operations" - }, - "errors": [ - { - "id": "{{errorId}}", - "status": "404", - "title": "The requested resource does not exist.", - "detail": "Resource of type 'musicTracks' with ID '{{musicTrackId}}' does not exist.", - "source": { - "pointer": "/atomic:operations[0]" - } - } - ] - } - """); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs deleted file mode 100644 index 6eb2ce3a3..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs +++ /dev/null @@ -1,333 +0,0 @@ -using System.Net; -using System.Text.Json; -using FluentAssertions; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Mixed; - -public sealed class AtomicTraceLoggingTests : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicTraceLoggingTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - testContext.ConfigureLogging(options => - { - var loggerProvider = new CapturingLoggerProvider((category, level) => - level >= LogLevel.Trace && category.StartsWith("JsonApiDotNetCore.", StringComparison.Ordinal)); - - options.AddProvider(loggerProvider); - options.SetMinimumLevel(LogLevel.Trace); - - options.Services.AddSingleton(loggerProvider); - }); - } - - [Fact] - public async Task Logs_execution_flow_at_Trace_level_on_operations_request() - { - // Arrange - var loggerProvider = _testContext.Factory.Services.GetRequiredService(); - loggerProvider.Clear(); - - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - existingTrack.Lyric = _fakers.Lyric.GenerateOne(); - existingTrack.OwnedBy = _fakers.RecordCompany.GenerateOne(); - existingTrack.Performers = _fakers.Performer.GenerateList(1); - - string newGenre = _fakers.MusicTrack.GenerateOne().Genre!; - - Lyric existingLyric = _fakers.Lyric.GenerateOne(); - RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); - Performer existingPerformer = _fakers.Performer.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingTrack, existingLyric, existingCompany, existingPerformer); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - attributes = new - { - genre = newGenre - }, - relationships = new - { - lyric = new - { - data = new - { - type = "lyrics", - id = existingLyric.StringId - } - }, - ownedBy = new - { - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } - }, - performers = new - { - data = new[] - { - new - { - type = "performers", - id = existingPerformer.StringId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - IReadOnlyList logLines = loggerProvider.GetLines(); - - logLines.Should().BeEquivalentTo(new[] - { - $$""" - [TRACE] Received POST request at 'http://localhost/operations' with body: <<{ - "atomic:operations": [ - { - "op": "update", - "data": { - "type": "musicTracks", - "id": "{{existingTrack.StringId}}", - "attributes": { - "genre": "{{newGenre}}" - }, - "relationships": { - "lyric": { - "data": { - "type": "lyrics", - "id": "{{existingLyric.StringId}}" - } - }, - "ownedBy": { - "data": { - "type": "recordCompanies", - "id": "{{existingCompany.StringId}}" - } - }, - "performers": { - "data": [ - { - "type": "performers", - "id": "{{existingPerformer.StringId}}" - } - ] - } - } - } - } - ] - }>> - """, - $$""" - [TRACE] Entering PostOperationsAsync(operations: [ - { - "Resource": { - "Id": "{{existingTrack.StringId}}", - "Genre": "{{newGenre}}", - "ReleasedAt": "0001-01-01T00:00:00+00:00", - "Lyric": { - "CreatedAt": "0001-01-01T00:00:00+00:00", - "Id": {{existingLyric.Id}}, - "StringId": "{{existingLyric.StringId}}" - }, - "OwnedBy": { - "Tracks": [], - "Id": {{existingCompany.Id}}, - "StringId": "{{existingCompany.StringId}}" - }, - "Performers": [ - { - "BornAt": "0001-01-01T00:00:00+00:00", - "Id": {{existingPerformer.Id}}, - "StringId": "{{existingPerformer.StringId}}" - } - ], - "OccursIn": [], - "StringId": "{{existingTrack.StringId}}" - }, - "TargetedFields": { - "Attributes": [ - "genre" - ], - "Relationships": [ - "lyric", - "ownedBy", - "performers" - ] - }, - "Request": { - "Kind": "AtomicOperations", - "PrimaryId": "{{existingTrack.StringId}}", - "PrimaryResourceType": "musicTracks", - "IsCollection": false, - "IsReadOnly": false, - "WriteOperation": "UpdateResource", - "Extensions": [] - } - } - ]) - """, - $$""" - [TRACE] Entering UpdateAsync(id: {{existingTrack.StringId}}, resource: { - "Id": "{{existingTrack.StringId}}", - "Genre": "{{newGenre}}", - "ReleasedAt": "0001-01-01T00:00:00+00:00", - "Lyric": { - "CreatedAt": "0001-01-01T00:00:00+00:00", - "Id": {{existingLyric.Id}}, - "StringId": "{{existingLyric.StringId}}" - }, - "OwnedBy": { - "Tracks": [], - "Id": {{existingCompany.Id}}, - "StringId": "{{existingCompany.StringId}}" - }, - "Performers": [ - { - "BornAt": "0001-01-01T00:00:00+00:00", - "Id": {{existingPerformer.Id}}, - "StringId": "{{existingPerformer.StringId}}" - } - ], - "OccursIn": [], - "StringId": "{{existingTrack.StringId}}" - }) - """, - $$""" - [TRACE] Entering GetForUpdateAsync(queryLayer: QueryLayer - { - Include: lyric,ownedBy,performers - Filter: equals(id,'{{existingTrack.StringId}}') - } - ) - """, - $$""" - [TRACE] Entering GetAsync(queryLayer: QueryLayer - { - Include: lyric,ownedBy,performers - Filter: equals(id,'{{existingTrack.StringId}}') - } - ) - """, - $$""" - [TRACE] Entering ApplyQueryLayer(queryLayer: QueryLayer - { - Include: lyric,ownedBy,performers - Filter: equals(id,'{{existingTrack.StringId}}') - } - ) - """, - $$""" - [TRACE] Entering UpdateAsync(resourceFromRequest: { - "Id": "{{existingTrack.StringId}}", - "Genre": "{{newGenre}}", - "ReleasedAt": "0001-01-01T00:00:00+00:00", - "Lyric": { - "CreatedAt": "0001-01-01T00:00:00+00:00", - "Id": {{existingLyric.Id}}, - "StringId": "{{existingLyric.StringId}}" - }, - "OwnedBy": { - "Tracks": [], - "Id": {{existingCompany.Id}}, - "StringId": "{{existingCompany.StringId}}" - }, - "Performers": [ - { - "BornAt": "0001-01-01T00:00:00+00:00", - "Id": {{existingPerformer.Id}}, - "StringId": "{{existingPerformer.StringId}}" - } - ], - "OccursIn": [], - "StringId": "{{existingTrack.StringId}}" - }, resourceFromDatabase: { - "Id": "{{existingTrack.StringId}}", - "Title": "{{existingTrack.Title}}", - "LengthInSeconds": {{JsonSerializer.Serialize(existingTrack.LengthInSeconds)}}, - "Genre": "{{existingTrack.Genre}}", - "ReleasedAt": {{JsonSerializer.Serialize(existingTrack.ReleasedAt)}}, - "Lyric": { - "Format": "{{existingTrack.Lyric.Format}}", - "Text": {{JsonSerializer.Serialize(existingTrack.Lyric.Text)}}, - "CreatedAt": "0001-01-01T00:00:00+00:00", - "Id": {{existingTrack.Lyric.Id}}, - "StringId": "{{existingTrack.Lyric.StringId}}" - }, - "OwnedBy": { - "Name": "{{existingTrack.OwnedBy.Name}}", - "CountryOfResidence": "{{existingTrack.OwnedBy.CountryOfResidence}}", - "Tracks": [ - null - ], - "Id": {{existingTrack.OwnedBy.Id}}, - "StringId": "{{existingTrack.OwnedBy.StringId}}" - }, - "Performers": [ - { - "ArtistName": "{{existingTrack.Performers[0].ArtistName}}", - "BornAt": {{JsonSerializer.Serialize(existingTrack.Performers[0].BornAt)}}, - "Id": {{existingTrack.Performers[0].Id}}, - "StringId": "{{existingTrack.Performers[0].StringId}}" - } - ], - "OccursIn": [], - "StringId": "{{existingTrack.StringId}}" - }) - """, - $$""" - [TRACE] Entering GetAsync(queryLayer: QueryLayer - { - Filter: equals(id,'{{existingTrack.StringId}}') - } - ) - """, - $$""" - [TRACE] Entering ApplyQueryLayer(queryLayer: QueryLayer - { - Filter: equals(id,'{{existingTrack.StringId}}') - } - ) - """ - }, options => options.Using(IgnoreLineEndingsComparer.Instance).WithStrictOrdering()); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs deleted file mode 100644 index f24e25a21..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs +++ /dev/null @@ -1,163 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Mixed; - -public sealed class MaximumOperationsPerRequestTests : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - - public MaximumOperationsPerRequestTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Cannot_process_more_operations_than_maximum() - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.MaximumOperationsPerRequest = 2; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "performers" - } - }, - new - { - op = "remove", - data = new - { - type = "performers" - } - }, - new - { - op = "update", - data = new - { - type = "performers" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Too many operations in request."); - error.Detail.Should().Be("The number of operations in this request (3) is higher than the maximum of 2."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Can_process_operations_same_as_maximum() - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.MaximumOperationsPerRequest = 2; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "performers", - attributes = new - { - } - } - }, - new - { - op = "add", - data = new - { - type = "performers", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - } - - [Fact] - public async Task Can_process_high_number_of_operations_when_unconstrained() - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.MaximumOperationsPerRequest = null; - - const int elementCount = 100; - - var operationElements = new List(elementCount); - - for (int index = 0; index < elementCount; index++) - { - operationElements.Add(new - { - op = "add", - data = new - { - type = "performers", - attributes = new - { - } - } - }); - } - - var requestBody = new - { - atomic__operations = operationElements - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs deleted file mode 100644 index 09596c2f0..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs +++ /dev/null @@ -1,618 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.ModelStateValidation; - -public sealed class AtomicModelStateValidationTests : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicModelStateValidationTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Cannot_create_resource_with_multiple_violations() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - lengthInSeconds = -1 - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(2); - - ErrorObject error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error1.Title.Should().Be("Input validation failed."); - error1.Detail.Should().Be("The Title field is required."); - error1.Source.ShouldNotBeNull(); - error1.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/title"); - - ErrorObject error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error2.Title.Should().Be("Input validation failed."); - error2.Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/lengthInSeconds"); - } - - [Fact] - public async Task Cannot_create_resource_when_violation_from_custom_ValidationAttribute() - { - // Arrange - var timeProvider = _testContext.Factory.Services.GetRequiredService(); - DateTimeOffset utcNow = timeProvider.GetUtcNow(); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = "some", - lengthInSeconds = 120, - releasedAt = utcNow.AddDays(1) - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Input validation failed."); - error.Detail.Should().Be("ReleasedAt must be in the past."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/releasedAt"); - } - - [Fact] - public async Task Can_create_resource_with_annotated_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - string newPlaylistName = _fakers.Playlist.GenerateOne().Name; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "playlists", - attributes = new - { - name = newPlaylistName - }, - relationships = new - { - tracks = new - { - data = new[] - { - new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(1); - - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().Id.ShouldNotBeNull()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); - - playlistInDatabase.Tracks.ShouldHaveCount(1); - playlistInDatabase.Tracks[0].Id.Should().Be(existingTrack.Id); - }); - } - - [Fact] - public async Task Cannot_update_resource_with_multiple_violations() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - attributes = new - { - title = (string?)null, - lengthInSeconds = -1 - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(2); - - ErrorObject error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error1.Title.Should().Be("Input validation failed."); - error1.Detail.Should().Be("The Title field is required."); - error1.Source.ShouldNotBeNull(); - error1.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/title"); - - ErrorObject error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error2.Title.Should().Be("Input validation failed."); - error2.Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/lengthInSeconds"); - } - - [Fact] - public async Task Can_update_resource_with_omitted_required_attribute() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - string newTrackGenre = _fakers.MusicTrack.GenerateOne().Genre!; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - attributes = new - { - genre = newTrackGenre - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Title.Should().Be(existingTrack.Title); - trackInDatabase.Genre.Should().Be(newTrackGenre); - }); - } - - [Fact] - public async Task Can_update_resource_with_annotated_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.GenerateOne(); - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingPlaylist, existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationships = new - { - tracks = new - { - data = new[] - { - new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - - playlistInDatabase.Tracks.ShouldHaveCount(1); - playlistInDatabase.Tracks[0].Id.Should().Be(existingTrack.Id); - }); - } - - [Fact] - public async Task Can_update_ManyToOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(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.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - }); - } - - [Fact] - public async Task Can_update_ManyToMany_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.GenerateOne(); - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingPlaylist, existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - - playlistInDatabase.Tracks.ShouldHaveCount(1); - playlistInDatabase.Tracks[0].Id.Should().Be(existingTrack.Id); - }); - } - - [Fact] - public async Task Validates_all_operations_before_execution_starts() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "update", - data = new - { - type = "playlists", - id = Unknown.StringId.For(), - attributes = new - { - name = (string?)null - } - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = "some", - lengthInSeconds = -1 - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(2); - - ErrorObject error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error1.Title.Should().Be("Input validation failed."); - error1.Detail.Should().Be("The Name field is required."); - error1.Source.ShouldNotBeNull(); - error1.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/name"); - - ErrorObject error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error2.Title.Should().Be("Input validation failed."); - error2.Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/atomic:operations[1]/data/attributes/lengthInSeconds"); - } - - [Fact] - public async Task Does_not_exceed_MaxModelValidationErrors() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "playlists", - attributes = new - { - name = (string?)null - } - } - }, - new - { - op = "add", - data = new - { - type = "playlists", - attributes = new - { - name = (string?)null - } - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - lengthInSeconds = -1 - } - } - }, - new - { - op = "add", - data = new - { - type = "playlists", - attributes = new - { - name = (string?)null - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(3); - - ErrorObject error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error1.Title.Should().Be("Input validation failed."); - error1.Detail.Should().Be("The maximum number of allowed model errors has been reached."); - error1.Source.Should().BeNull(); - - ErrorObject error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error2.Title.Should().Be("Input validation failed."); - error2.Detail.Should().Be("The Name field is required."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/name"); - - ErrorObject error3 = responseDocument.Errors[2]; - error3.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error3.Title.Should().Be("Input validation failed."); - error3.Detail.Should().Be("The Name field is required."); - error3.Source.ShouldNotBeNull(); - error3.Source.Pointer.Should().Be("/atomic:operations[1]/data/attributes/name"); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs deleted file mode 100644 index 0abf7385e..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; - -[UsedImplicitly(ImplicitUseTargetFlags.Members)] -[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations")] -public sealed class MusicTrack : Identifiable -{ - [RegularExpression("(?im)^[{(]?[0-9A-F]{8}[-]?(?:[0-9A-F]{4}[-]?){3}[0-9A-F]{12}[)}]?$")] - public override Guid Id { get; set; } - - [Attr] - public string Title { get; set; } = null!; - - [Attr] - [Range(1, 24 * 60)] - public decimal? LengthInSeconds { get; set; } - - [Attr] - public string? Genre { get; set; } - - [Attr] - [DateMustBeInThePast] - public DateTimeOffset ReleasedAt { get; set; } - - [HasOne] - public Lyric? Lyric { get; set; } - - [HasOne] - public RecordCompany? OwnedBy { get; set; } - - [HasMany] - public IList Performers { get; set; } = new List(); - - [HasMany(Capabilities = HasManyCapabilities.All & ~(HasManyCapabilities.AllowSet | HasManyCapabilities.AllowAdd | HasManyCapabilities.AllowRemove))] - public IList OccursIn { get; set; } = new List(); -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsController.cs deleted file mode 100644 index 2ea1b88ba..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsController.cs +++ /dev/null @@ -1,13 +0,0 @@ -using JsonApiDotNetCore.AtomicOperations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Resources; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; - -public sealed class OperationsController( - IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request, - ITargetedFields targetedFields, IAtomicOperationFilter operationFilter) - : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor, request, targetedFields, operationFilter); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs deleted file mode 100644 index e13a92294..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs +++ /dev/null @@ -1,33 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; - -// @formatter:wrap_chained_method_calls chop_always - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; - -[UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class OperationsDbContext(DbContextOptions options) - : TestableDbContext(options) -{ - public DbSet Playlists => Set(); - public DbSet MusicTracks => Set(); - public DbSet Lyrics => Set(); - public DbSet TextLanguages => Set(); - public DbSet Performers => Set(); - public DbSet RecordCompanies => Set(); - - protected override void OnModelCreating(ModelBuilder builder) - { - builder.Entity() - .HasOne(musicTrack => musicTrack.Lyric) - .WithOne(lyric => lyric.Track!) - .HasForeignKey("LyricId"); - - builder.Entity() - .HasMany(musicTrack => musicTrack.OccursIn) - .WithMany(playlist => playlist.Tracks); - - base.OnModelCreating(builder); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsFakers.cs deleted file mode 100644 index f09dfbdf0..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsFakers.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Globalization; -using Bogus; -using TestBuildingBlocks; - -// @formatter:wrap_chained_method_calls chop_if_long -// @formatter:wrap_before_first_method_call true - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; - -internal sealed class OperationsFakers -{ - private static readonly Lazy LazyLanguageIsoCodes = new(() => CultureInfo - .GetCultures(CultureTypes.NeutralCultures) - .Where(culture => !string.IsNullOrEmpty(culture.Name)) - .Select(culture => culture.Name) - .ToArray()); - - private readonly Lazy> _lazyPlaylistFaker = new(() => new Faker() - .MakeDeterministic() - .RuleFor(playlist => playlist.Name, faker => faker.Lorem.Sentence())); - - private readonly Lazy> _lazyMusicTrackFaker = new(() => new Faker() - .MakeDeterministic() - .RuleFor(musicTrack => musicTrack.Title, faker => faker.Lorem.Word()) - .RuleFor(musicTrack => musicTrack.LengthInSeconds, faker => faker.Random.Decimal(3 * 60, 5 * 60)) - .RuleFor(musicTrack => musicTrack.Genre, faker => faker.Lorem.Word()) - .RuleFor(musicTrack => musicTrack.ReleasedAt, faker => faker.Date.PastOffset().TruncateToWholeMilliseconds())); - - private readonly Lazy> _lazyLyricFaker = new(() => new Faker() - .MakeDeterministic() - .RuleFor(lyric => lyric.Text, faker => faker.Lorem.Text()) - .RuleFor(lyric => lyric.Format, "LRC")); - - private readonly Lazy> _lazyTextLanguageFaker = new(() => new Faker() - .MakeDeterministic() - .RuleFor(textLanguage => textLanguage.IsoCode, faker => faker.PickRandom(LazyLanguageIsoCodes.Value))); - - private readonly Lazy> _lazyPerformerFaker = new(() => new Faker() - .MakeDeterministic() - .RuleFor(performer => performer.ArtistName, faker => faker.Name.FullName()) - .RuleFor(performer => performer.BornAt, faker => faker.Date.PastOffset().TruncateToWholeMilliseconds())); - - private readonly Lazy> _lazyRecordCompanyFaker = new(() => new Faker() - .MakeDeterministic() - .RuleFor(recordCompany => recordCompany.Name, faker => faker.Company.CompanyName()) - .RuleFor(recordCompany => recordCompany.CountryOfResidence, faker => faker.Address.Country())); - - public Faker Playlist => _lazyPlaylistFaker.Value; - public Faker MusicTrack => _lazyMusicTrackFaker.Value; - public Faker Lyric => _lazyLyricFaker.Value; - public Faker TextLanguage => _lazyTextLanguageFaker.Value; - public Faker Performer => _lazyPerformerFaker.Value; - public Faker RecordCompany => _lazyRecordCompanyFaker.Value; -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Performer.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Performer.cs deleted file mode 100644 index 97e5a8d1d..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Performer.cs +++ /dev/null @@ -1,16 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; - -[UsedImplicitly(ImplicitUseTargetFlags.Members)] -[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations")] -public sealed class Performer : Identifiable -{ - [Attr] - public string? ArtistName { get; set; } - - [Attr] - public DateTimeOffset BornAt { get; set; } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Playlist.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Playlist.cs deleted file mode 100644 index e8baf731d..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Playlist.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.ComponentModel.DataAnnotations.Schema; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; - -[UsedImplicitly(ImplicitUseTargetFlags.Members)] -[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations")] -public sealed class Playlist : Identifiable -{ - [Attr] - public string Name { get; set; } = null!; - - [NotMapped] - [Attr] - public bool IsArchived => false; - - [HasMany] - public IList Tracks { get; set; } = new List(); -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs deleted file mode 100644 index e979e164c..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs +++ /dev/null @@ -1,342 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.QueryStrings; - -public sealed class AtomicQueryStringTests : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicQueryStringTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - - testContext.ConfigureServices(services => services.AddResourceDefinition()); - } - - [Fact] - public async Task Cannot_include_at_operations_endpoint() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "recordCompanies", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations?include=recordCompanies"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); - error.Detail.Should().Be("The parameter 'include' cannot be used at this endpoint."); - error.Source.ShouldNotBeNull(); - error.Source.Parameter.Should().Be("include"); - } - - [Fact] - public async Task Cannot_filter_at_operations_endpoint() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "recordCompanies", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations?filter=equals(id,'1')"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); - error.Detail.Should().Be("The parameter 'filter' cannot be used at this endpoint."); - error.Source.ShouldNotBeNull(); - error.Source.Parameter.Should().Be("filter"); - } - - [Fact] - public async Task Cannot_sort_at_operations_endpoint() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "recordCompanies", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations?sort=-id"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); - error.Detail.Should().Be("The parameter 'sort' cannot be used at this endpoint."); - error.Source.ShouldNotBeNull(); - error.Source.Parameter.Should().Be("sort"); - } - - [Fact] - public async Task Cannot_use_pagination_number_at_operations_endpoint() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "recordCompanies", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations?page[number]=1"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); - error.Detail.Should().Be("The parameter 'page[number]' cannot be used at this endpoint."); - error.Source.ShouldNotBeNull(); - error.Source.Parameter.Should().Be("page[number]"); - } - - [Fact] - public async Task Cannot_use_pagination_size_at_operations_endpoint() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "recordCompanies", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations?page[size]=1"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); - error.Detail.Should().Be("The parameter 'page[size]' cannot be used at this endpoint."); - error.Source.ShouldNotBeNull(); - error.Source.Parameter.Should().Be("page[size]"); - } - - [Fact] - public async Task Cannot_use_sparse_fieldset_at_operations_endpoint() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "recordCompanies", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations?fields[recordCompanies]=id"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); - error.Detail.Should().Be("The parameter 'fields[recordCompanies]' cannot be used at this endpoint."); - error.Source.ShouldNotBeNull(); - error.Source.Parameter.Should().Be("fields[recordCompanies]"); - } - - [Fact] - public async Task Can_use_Queryable_handler_at_resource_endpoint() - { - // Arrange - var timeProvider = _testContext.Factory.Services.GetRequiredService(); - DateTimeOffset utcNow = timeProvider.GetUtcNow(); - - List musicTracks = _fakers.MusicTrack.GenerateList(3); - musicTracks[0].ReleasedAt = utcNow.AddMonths(5); - musicTracks[1].ReleasedAt = utcNow.AddMonths(-5); - musicTracks[2].ReleasedAt = utcNow.AddMonths(-1); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.AddRange(musicTracks); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/musicTracks?isRecentlyReleased=true"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Id.Should().Be(musicTracks[2].StringId); - } - - [Fact] - public async Task Cannot_use_Queryable_handler_at_operations_endpoint() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTrackTitle - } - } - } - } - }; - - const string route = "/operations?isRecentlyReleased=true"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Unknown query string parameter."); - - error.Detail.Should().Be("Query string parameter 'isRecentlyReleased' is unknown. " + - "Set 'AllowUnknownQueryStringParameters' to 'true' in options to ignore unknown parameters."); - - error.Source.ShouldNotBeNull(); - error.Source.Parameter.Should().Be("isRecentlyReleased"); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs deleted file mode 100644 index b72b3eb3d..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs +++ /dev/null @@ -1,41 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using Microsoft.Extensions.Primitives; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.QueryStrings; - -[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class MusicTrackReleaseDefinition : JsonApiResourceDefinition -{ - private readonly TimeProvider _timeProvider; - - public MusicTrackReleaseDefinition(IResourceGraph resourceGraph, TimeProvider timeProvider) - : base(resourceGraph) - { - ArgumentNullException.ThrowIfNull(timeProvider); - - _timeProvider = timeProvider; - } - - public override QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters() - { - return new QueryStringParameterHandlers - { - ["isRecentlyReleased"] = FilterOnRecentlyReleased - }; - } - - private IQueryable FilterOnRecentlyReleased(IQueryable source, StringValues parameterValue) - { - IQueryable tracks = source; - - if (bool.Parse(parameterValue.ToString())) - { - DateTimeOffset utcNow = _timeProvider.GetUtcNow(); - tracks = tracks.Where(musicTrack => musicTrack.ReleasedAt < utcNow && musicTrack.ReleasedAt > utcNow.AddMonths(-3)); - } - - return tracks; - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/RecordCompany.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/RecordCompany.cs deleted file mode 100644 index 5e0509a57..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/RecordCompany.cs +++ /dev/null @@ -1,22 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; - -[UsedImplicitly(ImplicitUseTargetFlags.Members)] -[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations")] -public sealed class RecordCompany : Identifiable -{ - [Attr] - public string Name { get; set; } = null!; - - [Attr] - public string? CountryOfResidence { get; set; } - - [HasMany] - public IList Tracks { get; set; } = new List(); - - [HasOne] - public RecordCompany? Parent { get; set; } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs deleted file mode 100644 index 8e3c2ac7f..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs +++ /dev/null @@ -1,389 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.ResourceDefinitions.Serialization; - -public sealed class AtomicSerializationResourceDefinitionTests - : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicSerializationResourceDefinitionTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - testContext.ConfigureServices(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 - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - List newCompanies = _fakers.RecordCompany.GenerateList(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, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(2); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompanies[0].Name.ToUpperInvariant())); - - string countryOfResidence = newCompanies[0].CountryOfResidence!.ToUpperInvariant(); - resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(countryOfResidence)); - }); - - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompanies[1].Name.ToUpperInvariant())); - - string countryOfResidence = newCompanies[1].CountryOfResidence!.ToUpperInvariant(); - resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(countryOfResidence)); - }); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.ShouldHaveCount(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); - }); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnDeserialize), - (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnDeserialize), - (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnSerialize), - (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnSerialize) - }, options => options.WithStrictOrdering()); - } - - [Fact] - public async Task Skips_on_create_resource_with_ToOne_relationship() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); - - string newTrackTitle = _fakers.MusicTrack.GenerateOne().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, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(1); - - hitCounter.HitExtensibilityPoints.Should().BeEmpty(); - } - - [Fact] - public async Task Transforms_on_update_resource_with_side_effects() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - List existingCompanies = _fakers.RecordCompany.GenerateList(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, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(2); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(existingCompanies[0].Name)); - - string countryOfResidence = existingCompanies[0].CountryOfResidence!.ToUpperInvariant(); - resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(countryOfResidence)); - }); - - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(existingCompanies[1].Name)); - - string countryOfResidence = existingCompanies[1].CountryOfResidence!.ToUpperInvariant(); - resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(countryOfResidence)); - }); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.ShouldHaveCount(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); - }); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnDeserialize), - (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnDeserialize), - (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnSerialize), - (typeof(RecordCompany), ResourceDefinitionExtensibilityPoints.OnSerialize) - }, options => options.WithStrictOrdering()); - } - - [Fact] - public async Task Skips_on_update_resource_with_ToOne_relationship() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(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, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(1); - - hitCounter.HitExtensibilityPoints.Should().BeEmpty(); - } - - [Fact] - public async Task Skips_on_update_ToOne_relationship() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(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.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEmpty(); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs deleted file mode 100644 index 4c89170ef..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs +++ /dev/null @@ -1,31 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.ResourceDefinitions.Serialization; - -[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class RecordCompanyDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) - : HitCountingResourceDefinition(resourceGraph, hitCounter) -{ - protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.Serialization; - - public override void OnDeserialize(RecordCompany resource) - { - base.OnDeserialize(resource); - - if (!string.IsNullOrEmpty(resource.Name)) - { - resource.Name = resource.Name.ToUpperInvariant(); - } - } - - public override void OnSerialize(RecordCompany resource) - { - base.OnSerialize(resource); - - if (!string.IsNullOrEmpty(resource.CountryOfResidence)) - { - resource.CountryOfResidence = resource.CountryOfResidence.ToUpperInvariant(); - } - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs deleted file mode 100644 index 983a9c521..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs +++ /dev/null @@ -1,190 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.ResourceDefinitions.SparseFieldSets; - -public sealed class AtomicSparseFieldSetResourceDefinitionTests - : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicSparseFieldSetResourceDefinitionTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - testContext.ConfigureServices(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 Hides_text_in_create_resource_with_side_effects() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - var provider = _testContext.Factory.Services.GetRequiredService(); - provider.CanViewText = false; - - List newLyrics = _fakers.Lyric.GenerateList(2); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "lyrics", - attributes = new - { - format = newLyrics[0].Format, - text = newLyrics[0].Text - } - } - }, - new - { - op = "add", - data = new - { - type = "lyrics", - attributes = new - { - format = newLyrics[1].Format, - text = newLyrics[1].Text - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(2); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Attributes.ShouldContainKey("format").With(value => value.Should().Be(newLyrics[0].Format)); - resource.Attributes.Should().NotContainKey("text"); - }); - - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Attributes.ShouldContainKey("format").With(value => value.Should().Be(newLyrics[1].Format)); - resource.Attributes.Should().NotContainKey("text"); - }); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet) - }, options => options.WithStrictOrdering()); - } - - [Fact] - public async Task Hides_text_in_update_resource_with_side_effects() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - var provider = _testContext.Factory.Services.GetRequiredService(); - provider.CanViewText = false; - - List existingLyrics = _fakers.Lyric.GenerateList(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Lyrics.AddRange(existingLyrics); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "lyrics", - id = existingLyrics[0].StringId, - attributes = new - { - } - } - }, - new - { - op = "update", - data = new - { - type = "lyrics", - id = existingLyrics[1].StringId, - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(2); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Attributes.ShouldContainKey("format").With(value => value.Should().Be(existingLyrics[0].Format)); - resource.Attributes.Should().NotContainKey("text"); - }); - - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Attributes.ShouldContainKey("format").With(value => value.Should().Be(existingLyrics[1].Format)); - resource.Attributes.Should().NotContainKey("text"); - }); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet) - }, options => options.WithStrictOrdering()); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricPermissionProvider.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricPermissionProvider.cs deleted file mode 100644 index beea40bb3..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricPermissionProvider.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.ResourceDefinitions.SparseFieldSets; - -public sealed class LyricPermissionProvider -{ - internal bool CanViewText { get; set; } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs deleted file mode 100644 index 8f38b1aa3..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs +++ /dev/null @@ -1,21 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.ResourceDefinitions.SparseFieldSets; - -[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class LyricTextDefinition(IResourceGraph resourceGraph, LyricPermissionProvider lyricPermissionProvider, ResourceDefinitionHitCounter hitCounter) - : HitCountingResourceDefinition(resourceGraph, hitCounter) -{ - private readonly LyricPermissionProvider _lyricPermissionProvider = lyricPermissionProvider; - - protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet; - - public override SparseFieldSetExpression? OnApplySparseFieldSet(SparseFieldSetExpression? existingSparseFieldSet) - { - base.OnApplySparseFieldSet(existingSparseFieldSet); - - return _lyricPermissionProvider.CanViewText ? existingSparseFieldSet : existingSparseFieldSet.Excluding(lyric => lyric.Text, ResourceGraph); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguage.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguage.cs deleted file mode 100644 index e4e440600..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguage.cs +++ /dev/null @@ -1,21 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; - -[UsedImplicitly(ImplicitUseTargetFlags.Members)] -[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations", - GenerateControllerEndpoints = JsonApiEndpoints.Post | JsonApiEndpoints.Patch)] -public sealed class TextLanguage : Identifiable -{ - [Attr] - public string? IsoCode { get; set; } - - [Attr(Capabilities = AttrCapabilities.None)] - public bool IsRightToLeft { get; set; } - - [HasMany] - public ICollection Lyrics { get; set; } = new List(); -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs deleted file mode 100644 index b4f393d90..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs +++ /dev/null @@ -1,189 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions; - -public sealed class AtomicRollbackTests : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicRollbackTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Can_rollback_on_error() - { - // Arrange - string newArtistName = _fakers.Performer.GenerateOne().ArtistName!; - DateTimeOffset newBornAt = _fakers.Performer.GenerateOne().BornAt; - string newTitle = _fakers.MusicTrack.GenerateOne().Title; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTablesAsync(); - }); - - string unknownPerformerId = Unknown.StringId.For(); - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "performers", - attributes = new - { - artistName = newArtistName, - bornAt = newBornAt - } - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTitle - }, - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "performers", - id = unknownPerformerId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject 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 'performers' with ID '{unknownPerformerId}' in relationship 'performers' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().BeEmpty(); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().BeEmpty(); - }); - } - - [Fact] - public async Task Can_restore_to_previous_savepoint_on_error() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTablesAsync(); - }); - - const string trackLid = "track-1"; - - string unknownPerformerId = Unknown.StringId.For(); - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLid, - attributes = new - { - title = newTrackTitle - } - } - }, - new - { - op = "add", - @ref = new - { - type = "musicTracks", - lid = trackLid, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - id = unknownPerformerId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject 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 'performers' with ID '{unknownPerformerId}' in relationship 'performers' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().BeEmpty(); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().BeEmpty(); - }); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs deleted file mode 100644 index 4b5a6afc1..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs +++ /dev/null @@ -1,180 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions; - -public sealed class AtomicTransactionConsistencyTests - : IClassFixture, OperationsDbContext>>, IAsyncLifetime -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicTransactionConsistencyTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - testContext.ConfigureServices(services => - { - services.AddResourceRepository(); - services.AddResourceRepository(); - services.AddResourceRepository(); - - string dbConnectionString = - $"Host=localhost;Database=JsonApiTest-Extra-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true"; - - services.AddDbContext(options => options.UseNpgsql(dbConnectionString)); - }); - } - - [Fact] - public async Task Cannot_use_non_transactional_repository() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "performers", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Unsupported resource type in atomic:operations request."); - error.Detail.Should().Be("Operations on resources of type 'performers' cannot be used because transaction support is unavailable."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_use_transactional_repository_without_active_transaction() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTrackTitle - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Unsupported combination of resource types in atomic:operations request."); - error.Detail.Should().Be("All operations need to participate in a single shared transaction, which is not the case for this request."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_use_distributed_transaction() - { - // Arrange - string newLyricText = _fakers.Lyric.GenerateOne().Text; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "lyrics", - attributes = new - { - text = newLyricText - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Unsupported combination of resource types in atomic:operations request."); - error.Detail.Should().Be("All operations need to participate in a single shared transaction, which is not the case for this request."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - public Task InitializeAsync() - { - return Task.CompletedTask; - } - - Task IAsyncLifetime.DisposeAsync() - { - return DeleteExtraDatabaseAsync(); - } - - private async Task DeleteExtraDatabaseAsync() - { - await using AsyncServiceScope scope = _testContext.Factory.Services.CreateAsyncScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - - await dbContext.Database.EnsureDeletedAsync(); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/ExtraDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/ExtraDbContext.cs deleted file mode 100644 index 290efd343..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/ExtraDbContext.cs +++ /dev/null @@ -1,9 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions; - -[UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class ExtraDbContext(DbContextOptions options) - : TestableDbContext(options); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs deleted file mode 100644 index 98be0da66..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs +++ /dev/null @@ -1,27 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Repositories; -using JsonApiDotNetCore.Resources; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions; - -[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class LyricRepository : EntityFrameworkCoreRepository -{ - private readonly ExtraDbContext _extraDbContext; - - public override string? TransactionId => _extraDbContext.Database.CurrentTransaction?.TransactionId.ToString(); - - public LyricRepository(ExtraDbContext extraDbContext, ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) - { - _extraDbContext = extraDbContext; - - extraDbContext.Database.EnsureCreated(); - extraDbContext.Database.BeginTransaction(); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs deleted file mode 100644 index 7766b6757..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs +++ /dev/null @@ -1,18 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Repositories; -using JsonApiDotNetCore.Resources; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions; - -[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class MusicTrackRepository( - ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, - IEnumerable constraintProviders, ILoggerFactory loggerFactory, IResourceDefinitionAccessor resourceDefinitionAccessor) - : EntityFrameworkCoreRepository(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, - resourceDefinitionAccessor) -{ - public override string? TransactionId => null; -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs deleted file mode 100644 index 2302ac20f..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs +++ /dev/null @@ -1,61 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Repositories; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions; - -[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class PerformerRepository : IResourceRepository -{ - public Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task CountAsync(FilterExpression? filter, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task GetForCreateAsync(Type resourceClrType, int id, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task CreateAsync(Performer resourceFromRequest, Performer resourceForDatabase, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task UpdateAsync(Performer resourceFromRequest, Performer resourceFromDatabase, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task DeleteAsync(Performer? resourceFromDatabase, int id, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task SetRelationshipAsync(Performer leftResource, object? rightValue, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task AddToToManyRelationshipAsync(Performer? leftResource, int leftId, ISet rightResourceIds, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task RemoveFromToManyRelationshipAsync(Performer leftResource, ISet rightResourceIds, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs deleted file mode 100644 index e59b9d24d..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs +++ /dev/null @@ -1,1137 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Updating.Relationships; - -public sealed class AtomicAddToToManyRelationshipTests : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicAddToToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Cannot_add_to_ManyToOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingTrack, existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "ownedBy" - }, - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Forbidden); - error.Title.Should().Be("Failed to deserialize request body: Only to-many relationships can be targeted through this operation."); - error.Detail.Should().Be("Relationship 'ownedBy' is not a to-many relationship."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Can_add_to_OneToMany_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - existingTrack.Performers = _fakers.Performer.GenerateList(1); - - List existingPerformers = _fakers.Performer.GenerateList(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - dbContext.Performers.AddRange(existingPerformers); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - id = existingPerformers[0].StringId - } - } - }, - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - id = existingPerformers[1].StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Performers.ShouldHaveCount(3); - trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingTrack.Performers[0].Id); - trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); - trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); - }); - } - - [Fact] - public async Task Can_add_to_ManyToMany_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.GenerateOne(); - existingPlaylist.Tracks = _fakers.MusicTrack.GenerateList(1); - - List existingTracks = _fakers.MusicTrack.GenerateList(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Playlists.Add(existingPlaylist); - dbContext.MusicTracks.AddRange(existingTracks); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingTracks[0].StringId - } - } - }, - new - { - op = "add", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingTracks[1].StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - - playlistInDatabase.Tracks.ShouldHaveCount(3); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingPlaylist.Tracks[0].Id); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); - }); - } - - [Fact] - public async Task Cannot_add_for_href_element() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - href = "/api/musicTracks/1/relationships/performers" - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_add_for_missing_type_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - id = Unknown.StringId.For(), - relationship = "tracks" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_add_for_unknown_type_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.For(), - relationship = "tracks" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_add_for_missing_ID_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "musicTracks", - relationship = "performers" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_add_for_unknown_ID_in_ref() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - string companyId = Unknown.StringId.For(); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "recordCompanies", - id = companyId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject 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 'recordCompanies' with ID '{companyId}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.Should().NotContainKey("requestBody"); - } - - [Fact] - public async Task Cannot_add_for_ID_and_local_ID_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - lid = "local-1", - relationship = "performers" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_add_for_missing_relationship_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For() - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'relationship' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_add_for_unknown_relationship_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "performers", - id = Unknown.StringId.For(), - relationship = Unknown.Relationship - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'performers'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_add_for_missing_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_add_for_null_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = (object?)null - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of 'null'."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_add_for_object_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new - { - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of an object."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_add_for_missing_type_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "playlists", - id = Unknown.StringId.For(), - relationship = "tracks" - }, - data = new[] - { - new - { - id = Unknown.StringId.For() - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_add_for_unknown_type_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationship = "performers" - }, - data = new[] - { - new - { - type = Unknown.ResourceType, - id = Unknown.StringId.For() - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_add_for_missing_ID_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers" - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_add_for_ID_and_local_ID_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - id = Unknown.StringId.For(), - lid = "local-1" - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_add_for_unknown_IDs_in_data() - { - // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); - - string[] trackIds = - [ - Unknown.StringId.For(), - Unknown.StringId.AltFor() - ]; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "recordCompanies", - id = existingCompany.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = trackIds[0] - }, - new - { - type = "musicTracks", - id = trackIds[1] - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.ShouldHaveCount(2); - - ErrorObject error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.NotFound); - error1.Title.Should().Be("A related resource does not exist."); - error1.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[0]}' in relationship 'tracks' does not exist."); - error1.Source.ShouldNotBeNull(); - error1.Source.Pointer.Should().Be("/atomic:operations[0]"); - - ErrorObject error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.NotFound); - error2.Title.Should().Be("A related resource does not exist."); - error2.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[1]}' in relationship 'tracks' does not exist."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_add_for_relationship_mismatch_between_ref_and_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "playlists", - id = Unknown.StringId.For() - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Conflict); - error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is not convertible to type 'performers' of relationship 'performers'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Can_add_with_empty_data_array() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - existingTrack.Performers = _fakers.Performer.GenerateList(1); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = Array.Empty() - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Performers.ShouldHaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(existingTrack.Performers[0].Id); - }); - } - - [Fact] - public async Task Cannot_add_with_blocked_capability() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "occursIn" - }, - data = new - { - type = "playlists", - id = Unknown.StringId.For() - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Relationship cannot be added to."); - error.Detail.Should().Be("The relationship 'occursIn' on resource type 'musicTracks' cannot be added to."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs deleted file mode 100644 index f7bf47e98..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs +++ /dev/null @@ -1,1098 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Updating.Relationships; - -public sealed class AtomicRemoveFromToManyRelationshipTests : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicRemoveFromToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Cannot_remove_from_ManyToOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - existingTrack.OwnedBy = _fakers.RecordCompany.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "ownedBy" - }, - data = new - { - type = "recordCompanies", - id = existingTrack.OwnedBy.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Forbidden); - error.Title.Should().Be("Failed to deserialize request body: Only to-many relationships can be targeted through this operation."); - error.Detail.Should().Be("Relationship 'ownedBy' is not a to-many relationship."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Can_remove_from_OneToMany_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - existingTrack.Performers = _fakers.Performer.GenerateList(3); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - id = existingTrack.Performers[0].StringId - } - } - }, - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - id = existingTrack.Performers[2].StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Performers.ShouldHaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(existingTrack.Performers[1].Id); - - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.ShouldHaveCount(3); - }); - } - - [Fact] - public async Task Can_remove_from_ManyToMany_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.GenerateOne(); - existingPlaylist.Tracks = _fakers.MusicTrack.GenerateList(3); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Playlists.Add(existingPlaylist); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingPlaylist.Tracks[0].StringId - } - } - }, - new - { - op = "remove", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingPlaylist.Tracks[2].StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - - playlistInDatabase.Tracks.ShouldHaveCount(1); - playlistInDatabase.Tracks[0].Id.Should().Be(existingPlaylist.Tracks[1].Id); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - - tracksInDatabase.ShouldHaveCount(3); - }); - } - - [Fact] - public async Task Cannot_remove_for_href_element() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - href = "/api/musicTracks/1/relationships/performers" - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_remove_for_missing_type_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - id = Unknown.StringId.For(), - relationship = "tracks" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_remove_for_unknown_type_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.For(), - relationship = "tracks" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_remove_for_missing_ID_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - relationship = "performers" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_remove_for_unknown_ID_in_ref() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - string companyId = Unknown.StringId.For(); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "recordCompanies", - id = companyId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject 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 'recordCompanies' with ID '{companyId}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.Should().NotContainKey("requestBody"); - } - - [Fact] - public async Task Cannot_remove_for_ID_and_local_ID_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - lid = "local-1", - relationship = "performers" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_remove_for_unknown_relationship_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "performers", - id = Unknown.StringId.For(), - relationship = Unknown.Relationship - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'performers'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_remove_for_missing_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_remove_for_null_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = (object?)null - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of 'null'."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_remove_for_object_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new - { - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of an object."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_remove_for_missing_type_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "playlists", - id = Unknown.StringId.For(), - relationship = "tracks" - }, - data = new[] - { - new - { - id = Unknown.StringId.For() - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_remove_for_unknown_type_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationship = "performers" - }, - data = new[] - { - new - { - type = Unknown.ResourceType, - id = Unknown.StringId.For() - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_remove_for_missing_ID_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers" - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_remove_for_ID_and_local_ID_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - id = Unknown.StringId.For(), - lid = "local-1" - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_remove_for_unknown_IDs_in_data() - { - // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); - - string[] trackIds = - [ - Unknown.StringId.For(), - Unknown.StringId.AltFor() - ]; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "recordCompanies", - id = existingCompany.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = trackIds[0] - }, - new - { - type = "musicTracks", - id = trackIds[1] - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.ShouldHaveCount(2); - - ErrorObject error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.NotFound); - error1.Title.Should().Be("A related resource does not exist."); - error1.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[0]}' in relationship 'tracks' does not exist."); - error1.Source.ShouldNotBeNull(); - error1.Source.Pointer.Should().Be("/atomic:operations[0]"); - - ErrorObject error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.NotFound); - error2.Title.Should().Be("A related resource does not exist."); - error2.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[1]}' in relationship 'tracks' does not exist."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_remove_for_relationship_mismatch_between_ref_and_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "playlists", - id = Unknown.StringId.For() - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Conflict); - error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is not convertible to type 'performers' of relationship 'performers'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Can_remove_with_empty_data_array() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - existingTrack.Performers = _fakers.Performer.GenerateList(1); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = Array.Empty() - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Performers.ShouldHaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(existingTrack.Performers[0].Id); - }); - } - - [Fact] - public async Task Cannot_remove_with_blocked_capability() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "occursIn" - }, - data = new - { - type = "playlists", - id = Unknown.StringId.For() - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Relationship cannot be removed from."); - error.Detail.Should().Be("The relationship 'occursIn' on resource type 'musicTracks' cannot be removed from."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs deleted file mode 100644 index c8e093f9f..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs +++ /dev/null @@ -1,1196 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Updating.Relationships; - -public sealed class AtomicReplaceToManyRelationshipTests : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicReplaceToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Can_clear_OneToMany_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - existingTrack.Performers = _fakers.Performer.GenerateList(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = Array.Empty() - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Performers.Should().BeEmpty(); - - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.ShouldHaveCount(2); - }); - } - - [Fact] - public async Task Can_clear_ManyToMany_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.GenerateOne(); - existingPlaylist.Tracks = _fakers.MusicTrack.GenerateList(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Playlists.Add(existingPlaylist); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = Array.Empty() - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - - playlistInDatabase.Tracks.Should().BeEmpty(); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - - tracksInDatabase.ShouldHaveCount(2); - }); - } - - [Fact] - public async Task Can_replace_OneToMany_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - existingTrack.Performers = _fakers.Performer.GenerateList(1); - - List existingPerformers = _fakers.Performer.GenerateList(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - dbContext.Performers.AddRange(existingPerformers); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - id = existingPerformers[0].StringId - }, - new - { - type = "performers", - id = existingPerformers[1].StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Performers.ShouldHaveCount(2); - trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); - trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); - - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.ShouldHaveCount(3); - }); - } - - [Fact] - public async Task Can_replace_ManyToMany_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.GenerateOne(); - existingPlaylist.Tracks = _fakers.MusicTrack.GenerateList(1); - - List existingTracks = _fakers.MusicTrack.GenerateList(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Playlists.Add(existingPlaylist); - dbContext.MusicTracks.AddRange(existingTracks); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingTracks[0].StringId - }, - new - { - type = "musicTracks", - id = existingTracks[1].StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - - playlistInDatabase.Tracks.ShouldHaveCount(2); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - - tracksInDatabase.ShouldHaveCount(3); - }); - } - - [Fact] - public async Task Cannot_replace_for_href_element() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - href = "/api/musicTracks/1/relationships/performers" - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_replace_for_missing_type_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - id = Unknown.StringId.For(), - relationship = "tracks" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_replace_for_unknown_type_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.For(), - relationship = "tracks" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_replace_for_missing_ID_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - relationship = "performers" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_replace_for_unknown_ID_in_ref() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - string companyId = Unknown.StringId.For(); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "recordCompanies", - id = companyId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject 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 'recordCompanies' with ID '{companyId}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.Should().NotContainKey("requestBody"); - } - - [Fact] - public async Task Cannot_replace_for_incompatible_ID_in_ref() - { - // Arrange - string guid = Unknown.StringId.Guid; - - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "recordCompanies", - id = guid, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); - error.Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int16'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_replace_for_ID_and_local_ID_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - lid = "local-1", - relationship = "performers" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_replace_for_unknown_relationship_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "performers", - id = Unknown.StringId.For(), - relationship = Unknown.Relationship - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'performers'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_replace_for_missing_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_replace_for_null_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = (object?)null - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of 'null'."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_replace_for_object_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new - { - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of an object."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_replace_for_missing_type_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "playlists", - id = Unknown.StringId.For(), - relationship = "tracks" - }, - data = new[] - { - new - { - id = Unknown.StringId.For() - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_replace_for_unknown_type_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationship = "performers" - }, - data = new[] - { - new - { - type = Unknown.ResourceType, - id = Unknown.StringId.For() - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_replace_for_missing_ID_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers" - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_replace_for_ID_and_local_ID_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - id = Unknown.StringId.For(), - lid = "local-1" - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_replace_for_unknown_IDs_in_data() - { - // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); - - string[] trackIds = - [ - Unknown.StringId.For(), - Unknown.StringId.AltFor() - ]; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "recordCompanies", - id = existingCompany.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = trackIds[0] - }, - new - { - type = "musicTracks", - id = trackIds[1] - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.ShouldHaveCount(2); - - ErrorObject error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.NotFound); - error1.Title.Should().Be("A related resource does not exist."); - error1.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[0]}' in relationship 'tracks' does not exist."); - error1.Source.ShouldNotBeNull(); - error1.Source.Pointer.Should().Be("/atomic:operations[0]"); - - ErrorObject error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.NotFound); - error2.Title.Should().Be("A related resource does not exist."); - error2.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[1]}' in relationship 'tracks' does not exist."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_replace_for_incompatible_ID_in_data() - { - // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "recordCompanies", - id = existingCompany.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = "invalid-guid" - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); - error.Detail.Should().Be("Failed to convert 'invalid-guid' of type 'String' to type 'Guid'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_replace_for_relationship_mismatch_between_ref_and_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "playlists", - id = Unknown.StringId.For() - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Conflict); - error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is not convertible to type 'performers' of relationship 'performers'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_assign_relationship_with_blocked_capability() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "occursIn" - }, - data = new - { - type = "playlists", - id = Unknown.StringId.For() - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Relationship cannot be assigned."); - error.Detail.Should().Be("The relationship 'occursIn' on resource type 'musicTracks' cannot be assigned to."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs deleted file mode 100644 index e9f99c617..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs +++ /dev/null @@ -1,1361 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Updating.Relationships; - -public sealed class AtomicUpdateToOneRelationshipTests : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicUpdateToOneRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Can_clear_OneToOne_relationship_from_principal_side() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.GenerateOne(); - existingLyric.Track = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Lyrics.Add(existingLyric); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "lyrics", - id = existingLyric.StringId, - relationship = "track" - }, - data = (object?)null - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); - - lyricInDatabase.Track.Should().BeNull(); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.ShouldHaveCount(1); - }); - } - - [Fact] - public async Task Can_clear_OneToOne_relationship_from_dependent_side() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - existingTrack.Lyric = _fakers.Lyric.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "lyric" - }, - data = (object?)null - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Lyric.Should().BeNull(); - - List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.ShouldHaveCount(1); - }); - } - - [Fact] - public async Task Can_clear_ManyToOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - existingTrack.OwnedBy = _fakers.RecordCompany.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "ownedBy" - }, - data = (object?)null - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.OwnedBy.Should().BeNull(); - - List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.ShouldHaveCount(1); - }); - } - - [Fact] - public async Task Can_create_OneToOne_relationship_from_principal_side() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.GenerateOne(); - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingLyric, existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "lyrics", - id = existingLyric.StringId, - relationship = "track" - }, - data = new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); - - lyricInDatabase.Track.ShouldNotBeNull(); - lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); - }); - } - - [Fact] - public async Task Can_create_OneToOne_relationship_from_dependent_side() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - Lyric existingLyric = _fakers.Lyric.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingTrack, existingLyric); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "lyric" - }, - data = new - { - type = "lyrics", - id = existingLyric.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Lyric.ShouldNotBeNull(); - trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - }); - } - - [Fact] - public async Task Can_create_ManyToOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(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.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - }); - } - - [Fact] - public async Task Can_replace_OneToOne_relationship_from_principal_side() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.GenerateOne(); - existingLyric.Track = _fakers.MusicTrack.GenerateOne(); - - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddInRange(existingLyric, existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "lyrics", - id = existingLyric.StringId, - relationship = "track" - }, - data = new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); - - lyricInDatabase.Track.ShouldNotBeNull(); - lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.ShouldHaveCount(2); - }); - } - - [Fact] - public async Task Can_replace_OneToOne_relationship_from_dependent_side() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - existingTrack.Lyric = _fakers.Lyric.GenerateOne(); - - Lyric existingLyric = _fakers.Lyric.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddInRange(existingTrack, existingLyric); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "lyric" - }, - data = new - { - type = "lyrics", - id = existingLyric.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Lyric.ShouldNotBeNull(); - trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - - List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.ShouldHaveCount(2); - }); - } - - [Fact] - public async Task Can_replace_ManyToOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - existingTrack.OwnedBy = _fakers.RecordCompany.GenerateOne(); - - RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddInRange(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.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - - List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.ShouldHaveCount(2); - }); - } - - [Fact] - public async Task Cannot_create_for_href_element() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - href = "/api/musicTracks/1/relationships/ownedBy" - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_missing_type_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - id = Unknown.StringId.For(), - relationship = "track" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_unknown_type_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.For(), - relationship = "ownedBy" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_missing_ID_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - relationship = "ownedBy" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_unknown_ID_in_ref() - { - // Arrange - string trackId = Unknown.StringId.For(); - - Lyric existingLyric = _fakers.Lyric.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Lyrics.Add(existingLyric); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = trackId, - relationship = "lyric" - }, - data = new - { - type = "lyrics", - id = existingLyric.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject 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 'musicTracks' with ID '{trackId}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.Should().NotContainKey("requestBody"); - } - - [Fact] - public async Task Cannot_create_for_incompatible_ID_in_ref() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Lyrics.Add(existingLyric); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = "invalid-guid", - relationship = "lyric" - }, - data = new - { - type = "lyrics", - id = existingLyric.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); - error.Detail.Should().Be("Failed to convert 'invalid-guid' of type 'String' to type 'Guid'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_ID_and_local_ID_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - lid = "local-1", - relationship = "ownedBy" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_unknown_relationship_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "performers", - id = Unknown.StringId.For(), - relationship = Unknown.Relationship - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'performers'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_missing_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "lyric" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_array_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "lyric" - }, - data = new[] - { - new - { - type = "lyrics", - id = Unknown.StringId.For() - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an object or 'null', instead of an array."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_missing_type_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "lyrics", - id = Unknown.StringId.For(), - relationship = "track" - }, - data = new - { - id = Unknown.StringId.For() - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_unknown_type_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationship = "lyric" - }, - data = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.For() - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_missing_ID_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationship = "lyric" - }, - data = new - { - type = "lyrics" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_ID_and_local_ID_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationship = "lyric" - }, - data = new - { - type = "lyrics", - id = Unknown.StringId.For(), - lid = "local-1" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_unknown_ID_in_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - string lyricId = Unknown.StringId.For(); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "lyric" - }, - data = new - { - type = "lyrics", - id = lyricId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject 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 'lyrics' with ID '{lyricId}' in relationship 'lyric' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.Should().NotContainKey("requestBody"); - } - - [Fact] - public async Task Cannot_create_for_incompatible_ID_in_data() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Lyrics.Add(existingLyric); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "lyrics", - id = existingLyric.StringId, - relationship = "track" - }, - data = new - { - type = "musicTracks", - id = "invalid-guid" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); - error.Detail.Should().Be("Failed to convert 'invalid-guid' of type 'String' to type 'Guid'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_relationship_mismatch_between_ref_and_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "lyric" - }, - data = new - { - type = "playlists", - id = Unknown.StringId.For() - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Conflict); - error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is not convertible to type 'lyrics' of relationship 'lyric'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_assign_relationship_with_blocked_capability() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Lyrics.Add(existingLyric); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "lyrics", - id = existingLyric.StringId, - relationship = "language" - }, - data = new - { - type = "textLanguages", - id = Unknown.StringId.For() - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Relationship cannot be assigned."); - error.Detail.Should().Be("The relationship 'language' on resource type 'lyrics' cannot be assigned to."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs deleted file mode 100644 index 17778fc0a..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs +++ /dev/null @@ -1,863 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Updating.Resources; - -public sealed class AtomicReplaceToManyRelationshipTests : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicReplaceToManyRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Can_clear_OneToMany_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - existingTrack.Performers = _fakers.Performer.GenerateList(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - performers = new - { - data = Array.Empty() - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Performers.Should().BeEmpty(); - - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.ShouldHaveCount(2); - }); - } - - [Fact] - public async Task Can_clear_ManyToMany_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.GenerateOne(); - existingPlaylist.Tracks = _fakers.MusicTrack.GenerateList(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Playlists.Add(existingPlaylist); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationships = new - { - tracks = new - { - data = Array.Empty() - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - - playlistInDatabase.Tracks.Should().BeEmpty(); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - - tracksInDatabase.ShouldHaveCount(2); - }); - } - - [Fact] - public async Task Can_replace_OneToMany_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - existingTrack.Performers = _fakers.Performer.GenerateList(1); - - List existingPerformers = _fakers.Performer.GenerateList(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - dbContext.Performers.AddRange(existingPerformers); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "performers", - id = existingPerformers[0].StringId - }, - new - { - type = "performers", - id = existingPerformers[1].StringId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Performers.ShouldHaveCount(2); - trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); - trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); - - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.ShouldHaveCount(3); - }); - } - - [Fact] - public async Task Can_replace_ManyToMany_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.GenerateOne(); - existingPlaylist.Tracks = _fakers.MusicTrack.GenerateList(1); - - List existingTracks = _fakers.MusicTrack.GenerateList(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Playlists.Add(existingPlaylist); - dbContext.MusicTracks.AddRange(existingTracks); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationships = new - { - tracks = new - { - data = new[] - { - new - { - type = "musicTracks", - id = existingTracks[0].StringId - }, - new - { - type = "musicTracks", - id = existingTracks[1].StringId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - - playlistInDatabase.Tracks.ShouldHaveCount(2); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); - playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - - tracksInDatabase.ShouldHaveCount(3); - }); - } - - [Fact] - public async Task Cannot_replace_for_missing_data_in_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - performers = new - { - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_replace_for_null_data_in_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - performers = new - { - data = (object?)null - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of 'null'."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_replace_for_object_data_in_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - performers = new - { - data = new - { - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of an object."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_replace_for_missing_type_in_relationship_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "playlists", - id = Unknown.StringId.For(), - relationships = new - { - tracks = new - { - data = new[] - { - new - { - id = Unknown.StringId.For() - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/tracks/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_replace_for_unknown_type_in_relationship_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = Unknown.ResourceType, - id = Unknown.StringId.For() - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_replace_for_missing_ID_in_relationship_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "performers" - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_replace_for_ID_and_local_ID_relationship_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "performers", - id = Unknown.StringId.For(), - lid = "local-1" - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_replace_for_unknown_IDs_in_relationship_data() - { - // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); - - string[] trackIds = - [ - Unknown.StringId.For(), - Unknown.StringId.AltFor() - ]; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "recordCompanies", - id = existingCompany.StringId, - relationships = new - { - tracks = new - { - data = new[] - { - new - { - type = "musicTracks", - id = trackIds[0] - }, - new - { - type = "musicTracks", - id = trackIds[1] - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.ShouldHaveCount(2); - - ErrorObject error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.NotFound); - error1.Title.Should().Be("A related resource does not exist."); - error1.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[0]}' in relationship 'tracks' does not exist."); - error1.Source.ShouldNotBeNull(); - error1.Source.Pointer.Should().Be("/atomic:operations[0]"); - - ErrorObject error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.NotFound); - error2.Title.Should().Be("A related resource does not exist."); - error2.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[1]}' in relationship 'tracks' does not exist."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_relationship_mismatch() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "playlists", - id = Unknown.StringId.For() - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Conflict); - error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is not convertible to type 'performers' of relationship 'performers'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_assign_relationship_with_blocked_capability() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - occursIn = new - { - data = new[] - { - new - { - type = "playlists", - id = Unknown.StringId.For() - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Relationship cannot be assigned."); - error.Detail.Should().Be("The relationship 'occursIn' on resource type 'musicTracks' cannot be assigned to."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/occursIn"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs deleted file mode 100644 index 29a3e10fd..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs +++ /dev/null @@ -1,1818 +0,0 @@ -using System.Net; -using FluentAssertions; -using FluentAssertions.Extensions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Updating.Resources; - -public sealed class AtomicUpdateResourceTests : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicUpdateResourceTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController(); - - testContext.ConfigureServices(services => - { - services.AddResourceDefinition(); - - services.AddSingleton(); - }); - - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.AllowUnknownFieldsInRequestBody = false; - } - - [Fact] - public async Task Can_update_resources() - { - // Arrange - const int elementCount = 5; - - List existingTracks = _fakers.MusicTrack.GenerateList(elementCount); - string[] newTrackTitles = _fakers.MusicTrack.GenerateList(elementCount).Select(musicTrack => musicTrack.Title).ToArray(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.AddRange(existingTracks); - await dbContext.SaveChangesAsync(); - }); - - var operationElements = new List(elementCount); - - for (int index = 0; index < elementCount; index++) - { - operationElements.Add(new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTracks[index].StringId, - attributes = new - { - title = newTrackTitles[index] - } - } - }); - } - - var requestBody = new - { - atomic__operations = operationElements - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - - tracksInDatabase.ShouldHaveCount(elementCount); - - for (int index = 0; index < elementCount; index++) - { - MusicTrack trackInDatabase = tracksInDatabase.Single(musicTrack => musicTrack.Id == existingTracks[index].Id); - - trackInDatabase.Title.Should().Be(newTrackTitles[index]); - trackInDatabase.Genre.Should().Be(existingTracks[index].Genre); - } - }); - } - - [Fact] - public async Task Can_update_resource_without_attributes_or_relationships() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - existingTrack.OwnedBy = _fakers.RecordCompany.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Title.Should().Be(existingTrack.Title); - trackInDatabase.Genre.Should().Be(existingTrack.Genre); - - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); - }); - } - - [Fact] - public async Task Cannot_update_resource_with_unknown_attribute() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - string newTitle = _fakers.MusicTrack.GenerateOne().Title; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - attributes = new - { - title = newTitle, - doesNotExist = "Ignored" - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown attribute found."); - error.Detail.Should().Be("Attribute 'doesNotExist' does not exist on resource type 'musicTracks'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/doesNotExist"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Can_update_resource_with_unknown_attribute() - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.AllowUnknownFieldsInRequestBody = true; - - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - string newTitle = _fakers.MusicTrack.GenerateOne().Title; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - attributes = new - { - title = newTitle, - doesNotExist = "Ignored" - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Title.Should().Be(newTitle); - }); - } - - [Fact] - public async Task Cannot_update_resource_with_unknown_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - doesNotExist = new - { - data = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.Int32 - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); - error.Detail.Should().Be("Relationship 'doesNotExist' does not exist on resource type 'musicTracks'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/doesNotExist"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Can_update_resource_with_unknown_relationship() - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.AllowUnknownFieldsInRequestBody = true; - - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - doesNotExist = new - { - data = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.Int32 - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - } - - [Fact] - public async Task Can_partially_update_resource_without_side_effects() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - existingTrack.OwnedBy = _fakers.RecordCompany.GenerateOne(); - - string newGenre = _fakers.MusicTrack.GenerateOne().Genre!; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - attributes = new - { - genre = newGenre - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Title.Should().Be(existingTrack.Title); - trackInDatabase.LengthInSeconds.Should().BeApproximately(existingTrack.LengthInSeconds); - trackInDatabase.Genre.Should().Be(newGenre); - trackInDatabase.ReleasedAt.Should().Be(existingTrack.ReleasedAt); - - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); - }); - } - - [Fact] - public async Task Can_completely_update_resource_without_side_effects() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - existingTrack.OwnedBy = _fakers.RecordCompany.GenerateOne(); - - string newTitle = _fakers.MusicTrack.GenerateOne().Title; - decimal? newLengthInSeconds = _fakers.MusicTrack.GenerateOne().LengthInSeconds; - string newGenre = _fakers.MusicTrack.GenerateOne().Genre!; - DateTimeOffset newReleasedAt = _fakers.MusicTrack.GenerateOne().ReleasedAt; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - attributes = new - { - title = newTitle, - lengthInSeconds = newLengthInSeconds, - genre = newGenre, - releasedAt = newReleasedAt - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Title.Should().Be(newTitle); - trackInDatabase.LengthInSeconds.Should().BeApproximately(newLengthInSeconds); - trackInDatabase.Genre.Should().Be(newGenre); - trackInDatabase.ReleasedAt.Should().Be(newReleasedAt); - - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); - }); - } - - [Fact] - public async Task Can_update_resource_with_side_effects() - { - // Arrange - TextLanguage existingLanguage = _fakers.TextLanguage.GenerateOne(); - string newIsoCode = _fakers.TextLanguage.GenerateOne().IsoCode!; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TextLanguages.Add(existingLanguage); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "textLanguages", - id = existingLanguage.StringId, - attributes = new - { - isoCode = newIsoCode - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(1); - - string isoCode = $"{newIsoCode}{ImplicitlyChangingTextLanguageDefinition.Suffix}"; - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Type.Should().Be("textLanguages"); - resource.Attributes.ShouldContainKey("isoCode").With(value => value.Should().Be(isoCode)); - resource.Attributes.Should().NotContainKey("isRightToLeft"); - resource.Relationships.ShouldNotBeEmpty(); - }); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - TextLanguage languageInDatabase = await dbContext.TextLanguages.FirstWithIdAsync(existingLanguage.Id); - languageInDatabase.IsoCode.Should().Be(isoCode); - }); - } - - [Fact] - public async Task Update_resource_with_side_effects_hides_relationship_data_in_response() - { - // Arrange - TextLanguage existingLanguage = _fakers.TextLanguage.GenerateOne(); - existingLanguage.Lyrics = _fakers.Lyric.GenerateList(1); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TextLanguages.Add(existingLanguage); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "textLanguages", - id = existingLanguage.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(1); - - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => - { - resource.Relationships.ShouldNotBeEmpty(); - resource.Relationships.Values.Should().OnlyContain(value => value != null && value.Data.Value == null); - }); - } - - [Fact] - public async Task Cannot_update_resource_for_href_element() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - href = "/api/musicTracks/1" - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Can_update_resource_for_ref_element() - { - // Arrange - Performer existingPerformer = _fakers.Performer.GenerateOne(); - string newArtistName = _fakers.Performer.GenerateOne().ArtistName!; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "performers", - id = existingPerformer.StringId - }, - data = new - { - type = "performers", - id = existingPerformer.StringId, - attributes = new - { - artistName = newArtistName - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Performer performerInDatabase = await dbContext.Performers.FirstWithIdAsync(existingPerformer.Id); - - performerInDatabase.ArtistName.Should().Be(newArtistName); - performerInDatabase.BornAt.Should().Be(existingPerformer.BornAt); - }); - } - - [Fact] - public async Task Cannot_update_resource_for_missing_type_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - id = Unknown.StringId.For() - }, - data = new - { - type = "performers", - id = Unknown.StringId.For(), - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_update_resource_for_missing_ID_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "performers" - }, - data = new - { - type = "performers", - id = Unknown.StringId.For(), - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_update_resource_for_ID_and_local_ID_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "performers", - id = Unknown.StringId.For(), - lid = "local-1" - }, - data = new - { - type = "performers", - id = Unknown.StringId.AltFor(), - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_update_resource_for_missing_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update" - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_update_resource_for_null_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = (object?)null - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_update_resource_for_array_data() - { - // Arrange - Performer existingPerformer = _fakers.Performer.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new[] - { - new - { - type = "performers", - id = existingPerformer.StringId, - attributes = new - { - artistName = existingPerformer.ArtistName - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of an array."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_update_resource_for_missing_type_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - id = Unknown.StringId.Int32, - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_update_resource_for_missing_ID_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "performers", - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_update_resource_for_ID_and_local_ID_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "performers", - id = Unknown.StringId.For(), - lid = "local-1", - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_update_on_resource_type_mismatch_between_ref_and_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "performers", - id = Unknown.StringId.For() - }, - data = new - { - type = "playlists", - id = Unknown.StringId.For(), - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Conflict); - error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is not convertible to type 'performers'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_update_on_resource_ID_mismatch_between_ref_and_data() - { - // Arrange - string performerId1 = Unknown.StringId.For(); - string performerId2 = Unknown.StringId.AltFor(); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "performers", - id = performerId1 - }, - data = new - { - type = "performers", - id = performerId2, - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Conflict); - error.Title.Should().Be("Failed to deserialize request body: Conflicting 'id' values found."); - error.Detail.Should().Be($"Expected '{performerId1}' instead of '{performerId2}'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_update_on_resource_local_ID_mismatch_between_ref_and_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "performers", - lid = "local-1" - }, - data = new - { - type = "performers", - lid = "local-2", - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Conflict); - error.Title.Should().Be("Failed to deserialize request body: Conflicting 'lid' values found."); - error.Detail.Should().Be("Expected 'local-1' instead of 'local-2'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/lid"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_update_on_mixture_of_ID_and_local_ID_between_ref_and_data() - { - // Arrange - string performerId = Unknown.StringId.For(); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "performers", - id = performerId - }, - data = new - { - type = "performers", - lid = "local-1", - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_update_on_mixture_of_local_ID_and_ID_between_ref_and_data() - { - // Arrange - string performerId = Unknown.StringId.For(); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "performers", - lid = "local-1" - }, - data = new - { - type = "performers", - id = performerId, - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_update_resource_for_unknown_type() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.Int32, - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_update_resource_for_unknown_ID() - { - // Arrange - string performerId = Unknown.StringId.For(); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "performers", - id = performerId, - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject 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 'performers' with ID '{performerId}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.Should().NotContainKey("requestBody"); - } - - [Fact] - public async Task Cannot_update_resource_for_incompatible_ID() - { - // Arrange - string guid = Unknown.StringId.Guid; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "performers", - id = guid - }, - data = new - { - type = "performers", - id = guid, - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); - error.Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int32'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_update_resource_with_readonly_attribute() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Playlists.Add(existingPlaylist); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "playlists", - id = existingPlaylist.StringId, - attributes = new - { - isArchived = true - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Attribute is read-only."); - error.Detail.Should().Be("Attribute 'isArchived' on resource type 'playlists' is read-only."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/isArchived"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_change_ID_of_existing_resource() - { - // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "recordCompanies", - id = existingCompany.StringId, - attributes = new - { - id = (existingCompany.Id + 1).ToString() - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource ID is read-only."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_update_resource_with_incompatible_attribute_value() - { - // Arrange - Performer existingPerformer = _fakers.Performer.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "performers", - id = existingPerformer.StringId, - attributes = new - { - bornAt = 123.45 - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Incompatible attribute value found."); - error.Detail.Should().Be("Failed to convert attribute 'bornAt' with value '123.45' of type 'Number' to type 'DateTimeOffset'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/bornAt"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Can_update_resource_with_attributes_and_multiple_relationship_types() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - existingTrack.Lyric = _fakers.Lyric.GenerateOne(); - existingTrack.OwnedBy = _fakers.RecordCompany.GenerateOne(); - existingTrack.Performers = _fakers.Performer.GenerateList(1); - - string newGenre = _fakers.MusicTrack.GenerateOne().Genre!; - - Lyric existingLyric = _fakers.Lyric.GenerateOne(); - RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); - Performer existingPerformer = _fakers.Performer.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingTrack, existingLyric, existingCompany, existingPerformer); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - attributes = new - { - genre = newGenre - }, - relationships = new - { - lyric = new - { - data = new - { - type = "lyrics", - id = existingLyric.StringId - } - }, - ownedBy = new - { - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } - }, - performers = new - { - data = new[] - { - new - { - type = "performers", - id = existingPerformer.StringId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:wrap_after_property_in_chained_method_calls true - - MusicTrack trackInDatabase = await dbContext.MusicTracks - .Include(musicTrack => musicTrack.Lyric) - .Include(musicTrack => musicTrack.OwnedBy) - .Include(musicTrack => musicTrack.Performers) - .FirstWithIdAsync(existingTrack.Id); - - // @formatter:wrap_after_property_in_chained_method_calls restore - // @formatter:wrap_chained_method_calls restore - - trackInDatabase.Title.Should().Be(existingTrack.Title); - trackInDatabase.Genre.Should().Be(newGenre); - - trackInDatabase.Lyric.ShouldNotBeNull(); - trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - - trackInDatabase.Performers.ShouldHaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); - }); - } - - [Fact] - public async Task Cannot_assign_attribute_with_blocked_capability() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Lyrics.Add(existingLyric); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "lyrics", - id = existingLyric.StringId, - attributes = new - { - createdAt = 12.July(1980) - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Attribute value cannot be assigned when updating resource."); - error.Detail.Should().Be("The attribute 'createdAt' on resource type 'lyrics' cannot be assigned to."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/createdAt"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs deleted file mode 100644 index 0206ef1e9..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs +++ /dev/null @@ -1,1107 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Updating.Resources; - -public sealed class AtomicUpdateToOneRelationshipTests : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicUpdateToOneRelationshipTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Can_clear_OneToOne_relationship_from_principal_side() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.GenerateOne(); - existingLyric.Track = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Lyrics.Add(existingLyric); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "lyrics", - id = existingLyric.StringId, - relationships = new - { - track = new - { - data = (object?)null - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); - - lyricInDatabase.Track.Should().BeNull(); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.ShouldHaveCount(1); - }); - } - - [Fact] - public async Task Can_clear_OneToOne_relationship_from_dependent_side() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - existingTrack.Lyric = _fakers.Lyric.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - lyric = new - { - data = (object?)null - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Lyric.Should().BeNull(); - - List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.ShouldHaveCount(1); - }); - } - - [Fact] - public async Task Can_clear_ManyToOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - existingTrack.OwnedBy = _fakers.RecordCompany.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - ownedBy = new - { - data = (object?)null - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.OwnedBy.Should().BeNull(); - - List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.ShouldHaveCount(1); - }); - } - - [Fact] - public async Task Can_create_OneToOne_relationship_from_principal_side() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.GenerateOne(); - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingLyric, existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "lyrics", - id = existingLyric.StringId, - relationships = new - { - track = new - { - data = new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); - - lyricInDatabase.Track.ShouldNotBeNull(); - lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); - }); - } - - [Fact] - public async Task Can_create_OneToOne_relationship_from_dependent_side() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - Lyric existingLyric = _fakers.Lyric.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingTrack, existingLyric); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - lyric = new - { - data = new - { - type = "lyrics", - id = existingLyric.StringId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Lyric.ShouldNotBeNull(); - trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - }); - } - - [Fact] - public async Task Can_create_ManyToOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingTrack, existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - ownedBy = new - { - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - }); - } - - [Fact] - public async Task Can_replace_OneToOne_relationship_from_principal_side() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.GenerateOne(); - existingLyric.Track = _fakers.MusicTrack.GenerateOne(); - - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddInRange(existingLyric, existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "lyrics", - id = existingLyric.StringId, - relationships = new - { - track = new - { - data = new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); - - lyricInDatabase.Track.ShouldNotBeNull(); - lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.ShouldHaveCount(2); - }); - } - - [Fact] - public async Task Can_replace_OneToOne_relationship_from_dependent_side() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - existingTrack.Lyric = _fakers.Lyric.GenerateOne(); - - Lyric existingLyric = _fakers.Lyric.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddInRange(existingTrack, existingLyric); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - lyric = new - { - data = new - { - type = "lyrics", - id = existingLyric.StringId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Lyric.ShouldNotBeNull(); - trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - - List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.ShouldHaveCount(2); - }); - } - - [Fact] - public async Task Can_replace_ManyToOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - existingTrack.OwnedBy = _fakers.RecordCompany.GenerateOne(); - - RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddInRange(existingTrack, existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - ownedBy = new - { - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.OwnedBy.ShouldNotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - - List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.ShouldHaveCount(2); - }); - } - - [Fact] - public async Task Cannot_create_for_null_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - lyric = (object?)null - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_missing_data_in_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - lyric = new - { - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_array_data_in_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - lyric = new - { - data = new[] - { - new - { - type = "lyrics", - id = Unknown.StringId.For() - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected an object or 'null', instead of an array."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_missing_type_in_relationship_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "lyrics", - id = Unknown.StringId.For(), - relationships = new - { - track = new - { - data = new - { - id = Unknown.StringId.For() - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/track/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_unknown_type_in_relationship_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationships = new - { - lyric = new - { - data = new - { - type = Unknown.ResourceType, - id = Unknown.StringId.For() - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); - error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_missing_ID_in_relationship_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationships = new - { - lyric = new - { - data = new - { - type = "lyrics" - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' or 'lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_ID_and_local_ID_in_relationship_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = Unknown.StringId.For(), - relationships = new - { - lyric = new - { - data = new - { - type = "lyrics", - id = Unknown.StringId.For(), - lid = "local-1" - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); - error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_for_unknown_ID_in_relationship_data() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - string lyricId = Unknown.StringId.For(); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - lyric = new - { - data = new - { - type = "lyrics", - id = lyricId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject 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 'lyrics' with ID '{lyricId}' in relationship 'lyric' does not exist."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.Should().NotContainKey("requestBody"); - } - - [Fact] - public async Task Cannot_create_for_relationship_mismatch() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - lyric = new - { - data = new - { - type = "playlists", - id = Unknown.StringId.For() - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Conflict); - error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); - error.Detail.Should().Be("Type 'playlists' is not convertible to type 'lyrics' of relationship 'lyric'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_assign_relationship_with_blocked_capability() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.GenerateOne(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Lyrics.Add(existingLyric); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "lyrics", - id = existingLyric.StringId, - relationships = new - { - language = new - { - data = new - { - type = "textLanguages", - id = Unknown.StringId.For() - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.ShouldHaveCount(1); - - ErrorObject error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Relationship cannot be assigned."); - error.Detail.Should().Be("The relationship 'language' on resource type 'lyrics' cannot be assigned to."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/language"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } -}