diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/CapturingDocumentAdapter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/CapturingDocumentAdapter.cs new file mode 100644 index 0000000000..9ff423fda1 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/CapturingDocumentAdapter.cs @@ -0,0 +1,25 @@ +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Serialization.Request.Adapters; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Meta; + +internal sealed class CapturingDocumentAdapter : IDocumentAdapter +{ + private readonly IDocumentAdapter _innerAdapter; + private readonly RequestDocumentStore _requestDocumentStore; + + public CapturingDocumentAdapter(IDocumentAdapter innerAdapter, RequestDocumentStore requestDocumentStore) + { + ArgumentNullException.ThrowIfNull(innerAdapter); + ArgumentNullException.ThrowIfNull(requestDocumentStore); + + _innerAdapter = innerAdapter; + _requestDocumentStore = requestDocumentStore; + } + + public object? Convert(Document document) + { + _requestDocumentStore.Document = document; + return _innerAdapter.Convert(document); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaFakers.cs index 019dd6aa9b..07b41b4f0e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaFakers.cs @@ -16,6 +16,51 @@ internal sealed class MetaFakers .MakeDeterministic() .RuleFor(supportTicket => supportTicket.Description, faker => faker.Lorem.Paragraph())); + private readonly Lazy>> _lazyDocumentMetaFaker = new(() => new Faker>() + .MakeDeterministic() + .CustomInstantiator(faker => new Dictionary + { + ["requestId"] = faker.Random.Guid().ToString(), + ["isUrgent"] = faker.Random.Bool() + })); + + private readonly Lazy>> _lazyResourceMetaFaker = new(() => new Faker>() + .MakeDeterministic() + .CustomInstantiator(faker => new Dictionary + { + ["editedBy"] = faker.Internet.UserName(), + ["revision"] = faker.Random.Int(1, 10) + })); + + private readonly Lazy>> _lazyRelationshipMetaFaker = new(() => new Faker>() + .MakeDeterministic() + .CustomInstantiator(faker => new Dictionary + { + ["source"] = faker.PickRandom("ui", "api", "import"), + ["confidence"] = faker.Random.Double(0.1), + ["details"] = null + })); + + private readonly Lazy>> _lazyIdentifierMetaFaker = new(() => new Faker>() + .MakeDeterministic() + .CustomInstantiator(faker => new Dictionary + { + ["index"] = faker.IndexFaker, + ["optionalNote"] = faker.Lorem.Word() + })); + + private readonly Lazy>> _lazyOperationMetaFaker = new(() => new Faker>() + .MakeDeterministic() + .CustomInstantiator(faker => new Dictionary + { + ["version"] = faker.Random.Int(1, 10) + })); + public Faker ProductFamily => _lazyProductFamilyFaker.Value; public Faker SupportTicket => _lazySupportTicketFaker.Value; + public Faker> DocumentMeta => _lazyDocumentMetaFaker.Value; + public Faker> ResourceMeta => _lazyResourceMetaFaker.Value; + public Faker> RelationshipMeta => _lazyRelationshipMetaFaker.Value; + public Faker> IdentifierMeta => _lazyIdentifierMetaFaker.Value; + public Faker> OperationMeta => _lazyOperationMetaFaker.Value; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/OperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/OperationsController.cs new file mode 100644 index 0000000000..3679bafad3 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/OperationsController.cs @@ -0,0 +1,13 @@ +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Meta; + +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/Meta/OperationsRequestMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/OperationsRequestMetaTests.cs new file mode 100644 index 0000000000..77203ffabc --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/OperationsRequestMetaTests.cs @@ -0,0 +1,752 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Serialization.Request.Adapters; +using JsonApiDotNetCore.Serialization.Response; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Meta; + +public sealed class OperationsRequestMetaTests : IClassFixture, MetaDbContext>> +{ + private readonly IntegrationTestContext, MetaDbContext> _testContext; + private readonly MetaFakers _fakers = new(); + + public OperationsRequestMetaTests(IntegrationTestContext, MetaDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServices(services => + { + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + + services.AddScoped(serviceProvider => + { + var documentAdapter = serviceProvider.GetRequiredService(); + var requestDocumentStore = serviceProvider.GetRequiredService(); + return new CapturingDocumentAdapter(documentAdapter, requestDocumentStore); + }); + }); + } + + [Fact] + public async Task Accepts_meta_in_update_resource_operation_with_ToOne_relationship() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + + Dictionary documentMeta = _fakers.DocumentMeta.GenerateOne(); + Dictionary operationMeta = _fakers.OperationMeta.GenerateOne(); + Dictionary resourceMeta = _fakers.ResourceMeta.GenerateOne(); + Dictionary relationshipMeta = _fakers.RelationshipMeta.GenerateOne(); + Dictionary identifierMeta = _fakers.IdentifierMeta.GenerateOne(); + + SupportTicket existingTicket = _fakers.SupportTicket.GenerateOne(); + string newTicketDescription = _fakers.SupportTicket.GenerateOne().Description; + ProductFamily existingFamily = _fakers.ProductFamily.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.ProductFamilies.Add(existingFamily); + dbContext.SupportTickets.Add(existingTicket); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "supportTickets", + id = existingTicket.StringId, + attributes = new + { + description = newTicketDescription + }, + relationships = new + { + productFamily = new + { + data = new + { + type = "productFamilies", + id = existingFamily.StringId, + meta = identifierMeta + }, + meta = relationshipMeta + } + }, + meta = resourceMeta + }, + meta = operationMeta + } + }, + meta = documentMeta + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + store.Document.Should().NotBeNull(); + store.Document.Meta.Should().BeEquivalentToJson(documentMeta); + + store.Document.Operations.Should().ContainSingle().Which.With(operation => + { + operation.Should().NotBeNull(); + operation.Meta.Should().BeEquivalentToJson(operationMeta); + operation.Data.SingleValue.Should().NotBeNull(); + operation.Data.SingleValue.Meta.Should().BeEquivalentToJson(resourceMeta); + + operation.Data.SingleValue.Relationships.Should().ContainKey("productFamily").WhoseValue.With(value => + { + value.Should().NotBeNull(); + value.Meta.Should().BeEquivalentToJson(relationshipMeta); + value.Data.SingleValue.Should().NotBeNull(); + value.Data.SingleValue.Meta.Should().BeEquivalentToJson(identifierMeta); + }); + }); + } + + [Fact] + public async Task Accepts_meta_in_update_resource_operation_with_ToMany_relationship() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + + Dictionary documentMeta = _fakers.DocumentMeta.GenerateOne(); + Dictionary operationMeta = _fakers.OperationMeta.GenerateOne(); + Dictionary resourceMeta = _fakers.ResourceMeta.GenerateOne(); + Dictionary relationshipMeta = _fakers.RelationshipMeta.GenerateOne(); + Dictionary identifierMeta1 = _fakers.IdentifierMeta.GenerateOne(); + Dictionary identifierMeta2 = _fakers.IdentifierMeta.GenerateOne(); + + ProductFamily existingFamily = _fakers.ProductFamily.GenerateOne(); + string newFamilyName = _fakers.ProductFamily.GenerateOne().Name; + SupportTicket existingTicket1 = _fakers.SupportTicket.GenerateOne(); + SupportTicket existingTicket2 = _fakers.SupportTicket.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.ProductFamilies.Add(existingFamily); + dbContext.SupportTickets.AddRange(existingTicket1, existingTicket2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "productFamilies", + id = existingFamily.StringId, + attributes = new + { + name = newFamilyName + }, + relationships = new + { + tickets = new + { + data = new[] + { + new + { + type = "supportTickets", + id = existingTicket1.StringId, + meta = identifierMeta1 + }, + new + { + type = "supportTickets", + id = existingTicket2.StringId, + meta = identifierMeta2 + } + }, + meta = relationshipMeta + } + }, + meta = resourceMeta + }, + meta = operationMeta + } + }, + meta = documentMeta + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + store.Document.Should().NotBeNull(); + store.Document.Meta.Should().BeEquivalentToJson(documentMeta); + + store.Document.Operations.Should().ContainSingle().Which.With(operation => + { + operation.Should().NotBeNull(); + operation.Meta.Should().BeEquivalentToJson(operationMeta); + operation.Data.SingleValue.Should().NotBeNull(); + operation.Data.SingleValue.Meta.Should().BeEquivalentToJson(resourceMeta); + + operation.Data.SingleValue.Relationships.Should().ContainKey("tickets").WhoseValue.With(value => + { + value.Should().NotBeNull(); + value.Meta.Should().BeEquivalentToJson(relationshipMeta); + value.Data.ManyValue.Should().HaveCount(2); + value.Data.ManyValue[0].Meta.Should().BeEquivalentToJson(identifierMeta1); + value.Data.ManyValue[1].Meta.Should().BeEquivalentToJson(identifierMeta2); + }); + }); + } + + [Fact] + public async Task Accepts_meta_in_add_resource_operation_with_ToOne_relationship() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + + Dictionary documentMeta = _fakers.DocumentMeta.GenerateOne(); + Dictionary operationMeta = _fakers.OperationMeta.GenerateOne(); + Dictionary resourceMeta = _fakers.ResourceMeta.GenerateOne(); + Dictionary relationshipMeta = _fakers.RelationshipMeta.GenerateOne(); + Dictionary identifierMeta = _fakers.IdentifierMeta.GenerateOne(); + + string newTicketDescription = _fakers.SupportTicket.GenerateOne().Description; + ProductFamily existingFamily = _fakers.ProductFamily.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.ProductFamilies.Add(existingFamily); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "supportTickets", + attributes = new + { + description = newTicketDescription + }, + relationships = new + { + productFamily = new + { + data = new + { + type = "productFamilies", + id = existingFamily.StringId, + meta = identifierMeta + }, + meta = relationshipMeta + } + }, + meta = resourceMeta + }, + meta = operationMeta + } + }, + meta = documentMeta + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.Document.Should().NotBeNull(); + store.Document.Meta.Should().BeEquivalentToJson(documentMeta); + + store.Document.Operations.Should().ContainSingle().Which.With(operation => + { + operation.Should().NotBeNull(); + operation.Meta.Should().BeEquivalentToJson(operationMeta); + operation.Data.SingleValue.Should().NotBeNull(); + operation.Data.SingleValue.Meta.Should().BeEquivalentToJson(resourceMeta); + + operation.Data.SingleValue.Relationships.Should().ContainKey("productFamily").WhoseValue.With(value => + { + value.Should().NotBeNull(); + value.Meta.Should().BeEquivalentToJson(relationshipMeta); + value.Data.SingleValue.Should().NotBeNull(); + value.Data.SingleValue.Meta.Should().BeEquivalentToJson(identifierMeta); + }); + }); + } + + [Fact] + public async Task Accepts_meta_in_add_resource_operation_with_ToMany_relationship() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + + Dictionary documentMeta = _fakers.DocumentMeta.GenerateOne(); + Dictionary operationMeta = _fakers.OperationMeta.GenerateOne(); + Dictionary resourceMeta = _fakers.ResourceMeta.GenerateOne(); + Dictionary relationshipMeta = _fakers.RelationshipMeta.GenerateOne(); + Dictionary identifierMeta1 = _fakers.IdentifierMeta.GenerateOne(); + Dictionary identifierMeta2 = _fakers.IdentifierMeta.GenerateOne(); + + string newFamilyName = _fakers.ProductFamily.GenerateOne().Name; + SupportTicket existingTicket1 = _fakers.SupportTicket.GenerateOne(); + SupportTicket existingTicket2 = _fakers.SupportTicket.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.SupportTickets.AddRange(existingTicket1, existingTicket2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "productFamilies", + attributes = new + { + name = newFamilyName + }, + relationships = new + { + tickets = new + { + data = new[] + { + new + { + type = "supportTickets", + id = existingTicket1.StringId, + meta = identifierMeta1 + }, + new + { + type = "supportTickets", + id = existingTicket2.StringId, + meta = identifierMeta2 + } + }, + meta = relationshipMeta + } + }, + meta = resourceMeta + }, + meta = operationMeta + } + }, + meta = documentMeta + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.Document.Should().NotBeNull(); + store.Document.Meta.Should().BeEquivalentToJson(documentMeta); + + store.Document.Operations.Should().ContainSingle().Which.With(operation => + { + operation.Should().NotBeNull(); + operation.Meta.Should().BeEquivalentToJson(operationMeta); + operation.Data.SingleValue.Should().NotBeNull(); + operation.Data.SingleValue.Meta.Should().BeEquivalentToJson(resourceMeta); + + operation.Data.SingleValue.Relationships.Should().ContainKey("tickets").WhoseValue.With(value => + { + value.Should().NotBeNull(); + value.Meta.Should().BeEquivalentToJson(relationshipMeta); + value.Data.ManyValue.Should().HaveCount(2); + value.Data.ManyValue[0].Meta.Should().BeEquivalentToJson(identifierMeta1); + value.Data.ManyValue[1].Meta.Should().BeEquivalentToJson(identifierMeta2); + }); + }); + } + + [Fact] + public async Task Accepts_meta_in_update_ToOne_relationship_operation() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + + Dictionary documentMeta = _fakers.DocumentMeta.GenerateOne(); + Dictionary operationMeta = _fakers.OperationMeta.GenerateOne(); + Dictionary identifierMeta = _fakers.IdentifierMeta.GenerateOne(); + + SupportTicket existingTicket = _fakers.SupportTicket.GenerateOne(); + ProductFamily existingFamily = _fakers.ProductFamily.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.ProductFamilies.Add(existingFamily); + dbContext.SupportTickets.Add(existingTicket); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "supportTickets", + id = existingTicket.StringId, + relationship = "productFamily" + }, + data = new + { + type = "productFamilies", + id = existingFamily.StringId, + meta = identifierMeta + }, + meta = operationMeta + } + }, + meta = documentMeta + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + store.Document.Should().NotBeNull(); + store.Document.Meta.Should().BeEquivalentToJson(documentMeta); + + store.Document.Operations.Should().ContainSingle().Which.With(operation => + { + operation.Should().NotBeNull(); + operation.Meta.Should().BeEquivalentToJson(operationMeta); + operation.Data.SingleValue.Should().NotBeNull(); + operation.Data.SingleValue.Meta.Should().BeEquivalentToJson(identifierMeta); + }); + } + + [Fact] + public async Task Accepts_meta_in_update_ToMany_relationship_operation() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + + Dictionary documentMeta = _fakers.DocumentMeta.GenerateOne(); + Dictionary operationMeta = _fakers.OperationMeta.GenerateOne(); + Dictionary identifierMeta1 = _fakers.IdentifierMeta.GenerateOne(); + Dictionary identifierMeta2 = _fakers.IdentifierMeta.GenerateOne(); + + ProductFamily existingFamily = _fakers.ProductFamily.GenerateOne(); + SupportTicket existingTicket1 = _fakers.SupportTicket.GenerateOne(); + SupportTicket existingTicket2 = _fakers.SupportTicket.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.ProductFamilies.Add(existingFamily); + dbContext.SupportTickets.AddRange(existingTicket1, existingTicket2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "productFamilies", + id = existingFamily.StringId, + relationship = "tickets" + }, + data = new[] + { + new + { + type = "supportTickets", + id = existingTicket1.StringId, + meta = identifierMeta1 + }, + new + { + type = "supportTickets", + id = existingTicket2.StringId, + meta = identifierMeta2 + } + }, + meta = operationMeta + } + }, + meta = documentMeta + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + store.Document.Should().NotBeNull(); + store.Document.Meta.Should().BeEquivalentToJson(documentMeta); + + store.Document.Operations.Should().ContainSingle().Which.With(operation => + { + operation.Should().NotBeNull(); + operation.Meta.Should().BeEquivalentToJson(operationMeta); + operation.Data.ManyValue.Should().HaveCount(2); + operation.Data.ManyValue[0].Meta.Should().BeEquivalentToJson(identifierMeta1); + operation.Data.ManyValue[1].Meta.Should().BeEquivalentToJson(identifierMeta2); + }); + } + + [Fact] + public async Task Accepts_meta_in_add_to_ToMany_relationship_operation() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + + Dictionary documentMeta = _fakers.DocumentMeta.GenerateOne(); + Dictionary operationMeta = _fakers.OperationMeta.GenerateOne(); + Dictionary identifierMeta1 = _fakers.IdentifierMeta.GenerateOne(); + Dictionary identifierMeta2 = _fakers.IdentifierMeta.GenerateOne(); + + ProductFamily existingFamily = _fakers.ProductFamily.GenerateOne(); + SupportTicket existingTicket1 = _fakers.SupportTicket.GenerateOne(); + SupportTicket existingTicket2 = _fakers.SupportTicket.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.ProductFamilies.Add(existingFamily); + dbContext.SupportTickets.AddRange(existingTicket1, existingTicket2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "productFamilies", + id = existingFamily.StringId, + relationship = "tickets" + }, + data = new[] + { + new + { + type = "supportTickets", + id = existingTicket1.StringId, + meta = identifierMeta1 + }, + new + { + type = "supportTickets", + id = existingTicket2.StringId, + meta = identifierMeta2 + } + }, + meta = operationMeta + } + }, + meta = documentMeta + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + store.Document.Should().NotBeNull(); + store.Document.Meta.Should().BeEquivalentToJson(documentMeta); + + store.Document.Operations.Should().ContainSingle().Which.With(operation => + { + operation.Should().NotBeNull(); + operation.Meta.Should().BeEquivalentToJson(operationMeta); + operation.Data.ManyValue.Should().HaveCount(2); + operation.Data.ManyValue[0].Meta.Should().BeEquivalentToJson(identifierMeta1); + operation.Data.ManyValue[1].Meta.Should().BeEquivalentToJson(identifierMeta2); + }); + } + + [Fact] + public async Task Accepts_meta_in_remove_from_ToMany_relationship_operation() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + + Dictionary documentMeta = _fakers.DocumentMeta.GenerateOne(); + Dictionary operationMeta = _fakers.OperationMeta.GenerateOne(); + Dictionary identifierMeta1 = _fakers.IdentifierMeta.GenerateOne(); + Dictionary identifierMeta2 = _fakers.IdentifierMeta.GenerateOne(); + + ProductFamily existingFamily = _fakers.ProductFamily.GenerateOne(); + existingFamily.Tickets = _fakers.SupportTicket.GenerateList(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.ProductFamilies.Add(existingFamily); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "productFamilies", + id = existingFamily.StringId, + relationship = "tickets" + }, + data = new[] + { + new + { + type = "supportTickets", + id = existingFamily.Tickets[0].StringId, + meta = identifierMeta1 + }, + new + { + type = "supportTickets", + id = existingFamily.Tickets[1].StringId, + meta = identifierMeta2 + } + }, + meta = operationMeta + } + }, + meta = documentMeta + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + store.Document.Should().NotBeNull(); + store.Document.Meta.Should().BeEquivalentToJson(documentMeta); + + store.Document.Operations.Should().ContainSingle().Which.With(operation => + { + operation.Should().NotBeNull(); + operation.Meta.Should().BeEquivalentToJson(operationMeta); + operation.Data.ManyValue.Should().HaveCount(2); + operation.Data.ManyValue[0].Meta.Should().BeEquivalentToJson(identifierMeta1); + operation.Data.ManyValue[1].Meta.Should().BeEquivalentToJson(identifierMeta2); + }); + } + + [Fact] + public async Task Accepts_meta_in_remove_resource_operation() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + + Dictionary documentMeta = _fakers.DocumentMeta.GenerateOne(); + Dictionary operationMeta = _fakers.OperationMeta.GenerateOne(); + + SupportTicket existingTicket = _fakers.SupportTicket.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.SupportTickets.Add(existingTicket); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "supportTickets", + id = existingTicket.StringId + }, + meta = operationMeta + } + }, + meta = documentMeta + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + store.Document.Should().NotBeNull(); + store.Document.Meta.Should().BeEquivalentToJson(documentMeta); + + store.Document.Operations.Should().ContainSingle().Which.With(operation => + { + operation.Should().NotBeNull(); + operation.Meta.Should().BeEquivalentToJson(operationMeta); + }); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/RequestDocumentStore.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/RequestDocumentStore.cs new file mode 100644 index 0000000000..666b5d7c29 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/RequestDocumentStore.cs @@ -0,0 +1,8 @@ +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Meta; + +internal sealed class RequestDocumentStore +{ + public Document? Document { get; set; } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/RequestMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/RequestMetaTests.cs new file mode 100644 index 0000000000..63dca10a6f --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/RequestMetaTests.cs @@ -0,0 +1,554 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Serialization.Request.Adapters; +using JsonApiDotNetCore.Serialization.Response; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Meta; + +public sealed class RequestMetaTests : IClassFixture, MetaDbContext>> +{ + private readonly IntegrationTestContext, MetaDbContext> _testContext; + private readonly MetaFakers _fakers = new(); + + public RequestMetaTests(IntegrationTestContext, MetaDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServices(services => + { + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(); + + services.AddScoped(serviceProvider => + { + var documentAdapter = serviceProvider.GetRequiredService(); + var requestDocumentStore = serviceProvider.GetRequiredService(); + return new CapturingDocumentAdapter(documentAdapter, requestDocumentStore); + }); + }); + } + + [Fact] + public async Task Accepts_meta_in_update_resource_request_with_ToOne_relationship() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + + Dictionary documentMeta = _fakers.DocumentMeta.GenerateOne(); + Dictionary resourceMeta = _fakers.ResourceMeta.GenerateOne(); + Dictionary relationshipMeta = _fakers.RelationshipMeta.GenerateOne(); + Dictionary identifierMeta = _fakers.IdentifierMeta.GenerateOne(); + + SupportTicket existingTicket = _fakers.SupportTicket.GenerateOne(); + string newTicketDescription = _fakers.SupportTicket.GenerateOne().Description; + ProductFamily existingFamily = _fakers.ProductFamily.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.ProductFamilies.Add(existingFamily); + dbContext.SupportTickets.Add(existingTicket); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "supportTickets", + id = existingTicket.StringId, + attributes = new + { + description = newTicketDescription + }, + relationships = new + { + productFamily = new + { + data = new + { + type = "productFamilies", + id = existingFamily.StringId, + meta = identifierMeta + }, + meta = relationshipMeta + } + }, + meta = resourceMeta + }, + meta = documentMeta + }; + + string route = $"/supportTickets/{existingTicket.StringId}"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + store.Document.Should().NotBeNull(); + store.Document.Meta.Should().BeEquivalentToJson(documentMeta); + store.Document.Data.SingleValue.Should().NotBeNull(); + store.Document.Data.SingleValue.Meta.Should().BeEquivalentToJson(resourceMeta); + + store.Document.Data.SingleValue.Relationships.Should().ContainKey("productFamily").WhoseValue.With(value => + { + value.Should().NotBeNull(); + value.Meta.Should().BeEquivalentToJson(relationshipMeta); + value.Data.SingleValue.Should().NotBeNull(); + value.Data.SingleValue.Meta.Should().BeEquivalentToJson(identifierMeta); + }); + } + + [Fact] + public async Task Accepts_meta_in_update_resource_request_with_ToMany_relationship() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + + Dictionary documentMeta = _fakers.DocumentMeta.GenerateOne(); + Dictionary resourceMeta = _fakers.ResourceMeta.GenerateOne(); + Dictionary relationshipMeta = _fakers.RelationshipMeta.GenerateOne(); + Dictionary identifierMeta1 = _fakers.IdentifierMeta.GenerateOne(); + Dictionary identifierMeta2 = _fakers.IdentifierMeta.GenerateOne(); + + ProductFamily existingFamily = _fakers.ProductFamily.GenerateOne(); + string newFamilyName = _fakers.ProductFamily.GenerateOne().Name; + SupportTicket existingTicket1 = _fakers.SupportTicket.GenerateOne(); + SupportTicket existingTicket2 = _fakers.SupportTicket.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.ProductFamilies.Add(existingFamily); + dbContext.SupportTickets.AddRange(existingTicket1, existingTicket2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "productFamilies", + id = existingFamily.StringId, + attributes = new + { + name = newFamilyName + }, + relationships = new + { + tickets = new + { + data = new[] + { + new + { + type = "supportTickets", + id = existingTicket1.StringId, + meta = identifierMeta1 + }, + new + { + type = "supportTickets", + id = existingTicket2.StringId, + meta = identifierMeta2 + } + }, + meta = relationshipMeta + } + }, + meta = resourceMeta + }, + meta = documentMeta + }; + + string route = $"/productFamilies/{existingFamily.StringId}"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + store.Document.Should().NotBeNull(); + store.Document.Meta.Should().BeEquivalentToJson(documentMeta); + store.Document.Data.SingleValue.Should().NotBeNull(); + store.Document.Data.SingleValue.Meta.Should().BeEquivalentToJson(resourceMeta); + + store.Document.Data.SingleValue.Relationships.Should().ContainKey("tickets").WhoseValue.With(value => + { + value.Should().NotBeNull(); + value.Meta.Should().BeEquivalentToJson(relationshipMeta); + value.Data.ManyValue.Should().HaveCount(2); + value.Data.ManyValue[0].Meta.Should().BeEquivalentToJson(identifierMeta1); + value.Data.ManyValue[1].Meta.Should().BeEquivalentToJson(identifierMeta2); + }); + } + + [Fact] + public async Task Accepts_meta_in_add_resource_request_with_ToOne_relationship() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + + Dictionary documentMeta = _fakers.DocumentMeta.GenerateOne(); + Dictionary resourceMeta = _fakers.ResourceMeta.GenerateOne(); + Dictionary relationshipMeta = _fakers.RelationshipMeta.GenerateOne(); + Dictionary identifierMeta = _fakers.IdentifierMeta.GenerateOne(); + + string newTicketDescription = _fakers.SupportTicket.GenerateOne().Description; + ProductFamily existingFamily = _fakers.ProductFamily.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.ProductFamilies.Add(existingFamily); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "supportTickets", + attributes = new + { + description = newTicketDescription + }, + relationships = new + { + productFamily = new + { + data = new + { + type = "productFamilies", + id = existingFamily.StringId, + meta = identifierMeta + }, + meta = relationshipMeta + } + }, + meta = resourceMeta + }, + meta = documentMeta + }; + + const string route = "/supportTickets"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + store.Document.Should().NotBeNull(); + store.Document.Meta.Should().BeEquivalentToJson(documentMeta); + store.Document.Data.SingleValue.Should().NotBeNull(); + store.Document.Data.SingleValue.Meta.Should().BeEquivalentToJson(resourceMeta); + + store.Document.Data.SingleValue.Relationships.Should().ContainKey("productFamily").WhoseValue.With(value => + { + value.Should().NotBeNull(); + value.Meta.Should().BeEquivalentToJson(relationshipMeta); + value.Data.SingleValue.Should().NotBeNull(); + value.Data.SingleValue.Meta.Should().BeEquivalentToJson(identifierMeta); + }); + } + + [Fact] + public async Task Accepts_meta_in_add_resource_request_with_ToMany_relationship() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + + Dictionary documentMeta = _fakers.DocumentMeta.GenerateOne(); + Dictionary resourceMeta = _fakers.ResourceMeta.GenerateOne(); + Dictionary relationshipMeta = _fakers.RelationshipMeta.GenerateOne(); + Dictionary identifierMeta1 = _fakers.IdentifierMeta.GenerateOne(); + Dictionary identifierMeta2 = _fakers.IdentifierMeta.GenerateOne(); + + string newFamilyName = _fakers.ProductFamily.GenerateOne().Name; + SupportTicket existingTicket1 = _fakers.SupportTicket.GenerateOne(); + SupportTicket existingTicket2 = _fakers.SupportTicket.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.SupportTickets.AddRange(existingTicket1, existingTicket2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "productFamilies", + attributes = new + { + name = newFamilyName + }, + relationships = new + { + tickets = new + { + data = new[] + { + new + { + type = "supportTickets", + id = existingTicket1.StringId, + meta = identifierMeta1 + }, + new + { + type = "supportTickets", + id = existingTicket2.StringId, + meta = identifierMeta2 + } + }, + meta = relationshipMeta + } + }, + meta = resourceMeta + }, + meta = documentMeta + }; + + const string route = "/productFamilies"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + store.Document.Should().NotBeNull(); + store.Document.Meta.Should().BeEquivalentToJson(documentMeta); + store.Document.Data.SingleValue.Should().NotBeNull(); + store.Document.Data.SingleValue.Meta.Should().BeEquivalentToJson(resourceMeta); + + store.Document.Data.SingleValue.Relationships.Should().ContainKey("tickets").WhoseValue.With(value => + { + value.Should().NotBeNull(); + value.Meta.Should().BeEquivalentToJson(relationshipMeta); + value.Data.ManyValue.Should().HaveCount(2); + value.Data.ManyValue[0].Meta.Should().BeEquivalentToJson(identifierMeta1); + value.Data.ManyValue[1].Meta.Should().BeEquivalentToJson(identifierMeta2); + }); + } + + [Fact] + public async Task Accepts_meta_in_update_ToOne_relationship_request() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + + Dictionary documentMeta = _fakers.DocumentMeta.GenerateOne(); + Dictionary identifierMeta = _fakers.IdentifierMeta.GenerateOne(); + + SupportTicket existingTicket = _fakers.SupportTicket.GenerateOne(); + ProductFamily existingFamily = _fakers.ProductFamily.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.ProductFamilies.Add(existingFamily); + dbContext.SupportTickets.Add(existingTicket); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "productFamilies", + id = existingFamily.StringId, + meta = identifierMeta + }, + meta = documentMeta + }; + + string route = $"/supportTickets/{existingTicket.StringId}/relationships/productFamily"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + store.Document.Should().NotBeNull(); + store.Document.Meta.Should().BeEquivalentToJson(documentMeta); + store.Document.Data.SingleValue.Should().NotBeNull(); + store.Document.Data.SingleValue.Meta.Should().BeEquivalentToJson(identifierMeta); + } + + [Fact] + public async Task Accepts_meta_in_update_ToMany_relationship_request() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + + Dictionary documentMeta = _fakers.DocumentMeta.GenerateOne(); + Dictionary identifierMeta1 = _fakers.IdentifierMeta.GenerateOne(); + Dictionary identifierMeta2 = _fakers.IdentifierMeta.GenerateOne(); + + ProductFamily existingFamily = _fakers.ProductFamily.GenerateOne(); + SupportTicket existingTicket1 = _fakers.SupportTicket.GenerateOne(); + SupportTicket existingTicket2 = _fakers.SupportTicket.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.ProductFamilies.Add(existingFamily); + dbContext.SupportTickets.AddRange(existingTicket1, existingTicket2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "supportTickets", + id = existingTicket1.StringId, + meta = identifierMeta1 + }, + new + { + type = "supportTickets", + id = existingTicket2.StringId, + meta = identifierMeta2 + } + }, + meta = documentMeta + }; + + string route = $"/productFamilies/{existingFamily.StringId}/relationships/tickets"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + store.Document.Should().NotBeNull(); + store.Document.Meta.Should().BeEquivalentToJson(documentMeta); + store.Document.Data.ManyValue.Should().HaveCount(2); + store.Document.Data.ManyValue[0].Meta.Should().BeEquivalentToJson(identifierMeta1); + store.Document.Data.ManyValue[1].Meta.Should().BeEquivalentToJson(identifierMeta2); + } + + [Fact] + public async Task Accepts_meta_in_add_to_ToMany_relationship_request() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + + Dictionary documentMeta = _fakers.DocumentMeta.GenerateOne(); + Dictionary identifierMeta1 = _fakers.IdentifierMeta.GenerateOne(); + Dictionary identifierMeta2 = _fakers.IdentifierMeta.GenerateOne(); + + ProductFamily existingFamily = _fakers.ProductFamily.GenerateOne(); + SupportTicket existingTicket1 = _fakers.SupportTicket.GenerateOne(); + SupportTicket existingTicket2 = _fakers.SupportTicket.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.ProductFamilies.Add(existingFamily); + dbContext.SupportTickets.AddRange(existingTicket1, existingTicket2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "supportTickets", + id = existingTicket1.StringId, + meta = identifierMeta1 + }, + new + { + type = "supportTickets", + id = existingTicket2.StringId, + meta = identifierMeta2 + } + }, + meta = documentMeta + }; + + string route = $"/productFamilies/{existingFamily.StringId}/relationships/tickets"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + store.Document.Should().NotBeNull(); + store.Document.Meta.Should().BeEquivalentToJson(documentMeta); + store.Document.Data.ManyValue.Should().HaveCount(2); + store.Document.Data.ManyValue[0].Meta.Should().BeEquivalentToJson(identifierMeta1); + store.Document.Data.ManyValue[1].Meta.Should().BeEquivalentToJson(identifierMeta2); + } + + [Fact] + public async Task Accepts_meta_in_remove_from_ToMany_relationship_request() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + + Dictionary documentMeta = _fakers.DocumentMeta.GenerateOne(); + Dictionary identifierMeta1 = _fakers.IdentifierMeta.GenerateOne(); + Dictionary identifierMeta2 = _fakers.IdentifierMeta.GenerateOne(); + + ProductFamily existingFamily = _fakers.ProductFamily.GenerateOne(); + existingFamily.Tickets = _fakers.SupportTicket.GenerateList(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.ProductFamilies.Add(existingFamily); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "supportTickets", + id = existingFamily.Tickets[0].StringId, + meta = identifierMeta1 + }, + new + { + type = "supportTickets", + id = existingFamily.Tickets[1].StringId, + meta = identifierMeta2 + } + }, + meta = documentMeta + }; + + string route = $"/productFamilies/{existingFamily.StringId}/relationships/tickets"; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + store.Document.Should().NotBeNull(); + store.Document.Meta.Should().BeEquivalentToJson(documentMeta); + store.Document.Data.ManyValue.Should().HaveCount(2); + store.Document.Data.ManyValue[0].Meta.Should().BeEquivalentToJson(identifierMeta1); + store.Document.Data.ManyValue[1].Meta.Should().BeEquivalentToJson(identifierMeta2); + } +} diff --git a/test/TestBuildingBlocks/FluentMetaExtensions.cs b/test/TestBuildingBlocks/FluentMetaExtensions.cs index 72a5a8bb2c..fac9431613 100644 --- a/test/TestBuildingBlocks/FluentMetaExtensions.cs +++ b/test/TestBuildingBlocks/FluentMetaExtensions.cs @@ -1,4 +1,6 @@ +using System.Text.Encodings.Web; using System.Text.Json; +using System.Text.Json.Serialization; using FluentAssertions; using FluentAssertions.Collections; @@ -6,6 +8,13 @@ namespace TestBuildingBlocks; public static class FluentMetaExtensions { + private static readonly JsonSerializerOptions MetaSerializerOptions = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + ReferenceHandler = ReferenceHandler.IgnoreCycles + }; + /// /// Asserts that a "meta" dictionary contains a single element named "total" with the specified value. /// @@ -77,4 +86,19 @@ private static JsonElement GetMetaJsonElement(GenericDictionaryAssertions().Subject; } + + /// + /// Asserts that the content of a "meta" dictionary matches the expected structure and values, after conversion to JSON. + /// + [CustomAssertion] + public static void BeEquivalentToJson(this GenericDictionaryAssertions, string, object?> source, + Dictionary expected) + { + source.NotBeNull(); + + string sourceJson = JsonSerializer.Serialize(source.Subject, MetaSerializerOptions); + string expectedJson = JsonSerializer.Serialize(expected, MetaSerializerOptions); + + sourceJson.Should().Be(expectedJson); + } }