diff --git a/ROADMAP.md b/ROADMAP.md index b45d4cef19..d0f678a10e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,11 +23,11 @@ The need for breaking changes has blocked several efforts in the v4.x release, s - [x] Optimize IIdentifiable to ResourceObject conversion [#1028](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1028) [#1024](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1024) [#233](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/233) - [x] Nullable reference types [#1029](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1029) - [x] Improved paging links [#1010](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1010) +- [x] Configuration validation [#170](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/170) Aside from the list above, we have interest in the following topics. It's too soon yet to decide whether they'll make it into v5.x or in a later major version. - Auto-generated controllers [#732](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/732) [#365](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/365) -- Configuration validation [#170](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/170) - Optimistic concurrency [#1004](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1004) - Extract annotations into separate package [#730](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/730) - OpenAPI (Swagger) [#1046](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1046) diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index d405537374..7d545a8490 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -21,7 +21,7 @@ public class ResourceGraphBuilder { private readonly IJsonApiOptions _options; private readonly ILogger _logger; - private readonly HashSet _resourceTypes = new(); + private readonly Dictionary _resourceTypesByClrType = new(); private readonly TypeLocator _typeLocator = new(); public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactory) @@ -38,12 +38,27 @@ public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactor /// public IResourceGraph Build() { - var resourceGraph = new ResourceGraph(_resourceTypes); + HashSet resourceTypes = _resourceTypesByClrType.Values.ToHashSet(); - foreach (RelationshipAttribute relationship in _resourceTypes.SelectMany(resourceType => resourceType.Relationships)) + if (!resourceTypes.Any()) + { + _logger.LogWarning("The resource graph is empty."); + } + + var resourceGraph = new ResourceGraph(resourceTypes); + + foreach (RelationshipAttribute relationship in resourceTypes.SelectMany(resourceType => resourceType.Relationships)) { relationship.LeftType = resourceGraph.GetResourceType(relationship.LeftClrType!); - relationship.RightType = resourceGraph.GetResourceType(relationship.RightClrType!); + ResourceType? rightType = resourceGraph.FindResourceType(relationship.RightClrType!); + + if (rightType == null) + { + throw new InvalidConfigurationException($"Resource type '{relationship.LeftClrType}' depends on " + + $"'{relationship.RightClrType}', which was not added to the resource graph."); + } + + relationship.RightType = rightType; } return resourceGraph; @@ -123,7 +138,7 @@ public ResourceGraphBuilder Add(Type resourceClrType, Type? idClrType = null, st { ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); - if (_resourceTypes.Any(resourceType => resourceType.ClrType == resourceClrType)) + if (_resourceTypesByClrType.ContainsKey(resourceClrType)) { return this; } @@ -139,7 +154,10 @@ public ResourceGraphBuilder Add(Type resourceClrType, Type? idClrType = null, st } ResourceType resourceType = CreateResourceType(effectivePublicName, resourceClrType, effectiveIdType); - _resourceTypes.Add(resourceType); + + AssertNoDuplicatePublicName(resourceType, effectivePublicName); + + _resourceTypesByClrType.Add(resourceClrType, resourceType); } else { @@ -155,6 +173,8 @@ private ResourceType CreateResourceType(string publicName, Type resourceClrType, IReadOnlyCollection relationships = GetRelationships(resourceClrType); IReadOnlyCollection eagerLoads = GetEagerLoads(resourceClrType); + AssertNoDuplicatePublicName(attributes, relationships); + var linksAttribute = (ResourceLinksAttribute?)resourceClrType.GetCustomAttribute(typeof(ResourceLinksAttribute)); return linksAttribute == null @@ -165,7 +185,7 @@ private ResourceType CreateResourceType(string publicName, Type resourceClrType, private IReadOnlyCollection GetAttributes(Type resourceClrType) { - var attributes = new List(); + var attributesByName = new Dictionary(); foreach (PropertyInfo property in resourceClrType.GetProperties()) { @@ -181,7 +201,7 @@ private IReadOnlyCollection GetAttributes(Type resourceClrType) Capabilities = _options.DefaultAttrCapabilities }; - attributes.Add(idAttr); + IncludeField(attributesByName, idAttr); continue; } @@ -200,15 +220,20 @@ private IReadOnlyCollection GetAttributes(Type resourceClrType) attribute.Capabilities = _options.DefaultAttrCapabilities; } - attributes.Add(attribute); + IncludeField(attributesByName, attribute); } - return attributes; + if (attributesByName.Count < 2) + { + _logger.LogWarning($"Type '{resourceClrType}' does not contain any attributes."); + } + + return attributesByName.Values; } private IReadOnlyCollection GetRelationships(Type resourceClrType) { - var relationships = new List(); + var relationshipsByName = new Dictionary(); PropertyInfo[] properties = resourceClrType.GetProperties(); foreach (PropertyInfo property in properties) @@ -222,11 +247,11 @@ private IReadOnlyCollection GetRelationships(Type resourc relationship.LeftClrType = resourceClrType; relationship.RightClrType = GetRelationshipType(relationship, property); - relationships.Add(relationship); + IncludeField(relationshipsByName, relationship); } } - return relationships; + return relationshipsByName.Values; } private void SetPublicName(ResourceFieldAttribute field, PropertyInfo property) @@ -269,6 +294,51 @@ private IReadOnlyCollection GetEagerLoads(Type resourceClrTy return attributes; } + private static void IncludeField(Dictionary fieldsByName, TField field) + where TField : ResourceFieldAttribute + { + if (fieldsByName.TryGetValue(field.PublicName, out var existingField)) + { + throw CreateExceptionForDuplicatePublicName(field.Property.DeclaringType!, existingField, field); + } + + fieldsByName.Add(field.PublicName, field); + } + + private void AssertNoDuplicatePublicName(ResourceType resourceType, string effectivePublicName) + { + var (existingClrType, _) = _resourceTypesByClrType.FirstOrDefault(type => type.Value.PublicName == resourceType.PublicName); + + if (existingClrType != null) + { + throw new InvalidConfigurationException( + $"Resource '{existingClrType}' and '{resourceType.ClrType}' both use public name '{effectivePublicName}'."); + } + } + + private void AssertNoDuplicatePublicName(IReadOnlyCollection attributes, IReadOnlyCollection relationships) + { + IEnumerable<(AttrAttribute attribute, RelationshipAttribute relationship)> query = + from attribute in attributes + from relationship in relationships + where attribute.PublicName == relationship.PublicName + select (attribute, relationship); + + (AttrAttribute? duplicateAttribute, RelationshipAttribute? duplicateRelationship) = query.FirstOrDefault(); + + if (duplicateAttribute != null && duplicateRelationship != null) + { + throw CreateExceptionForDuplicatePublicName(duplicateAttribute.Property.DeclaringType!, duplicateAttribute, duplicateRelationship); + } + } + + private static InvalidConfigurationException CreateExceptionForDuplicatePublicName(Type containingClrType, ResourceFieldAttribute existingField, + ResourceFieldAttribute field) + { + return new InvalidConfigurationException( + $"Properties '{containingClrType}.{existingField.Property.Name}' and '{containingClrType}.{field.Property.Name}' both use public name '{field.PublicName}'."); + } + [AssertionMethod] private static void AssertNoInfiniteRecursion(int recursionDepth) { diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs index a6b7e426c9..04ceb1c038 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs @@ -84,6 +84,11 @@ public void Apply(ApplicationModel application) _resourceTypePerControllerTypeMap.Add(controller.ControllerType, resourceType); _controllerPerResourceTypeMap.Add(resourceType, controller); } + else + { + throw new InvalidConfigurationException($"Controller '{controller.ControllerType}' depends on " + + $"resource type '{resourceClrType}', which does not exist in the resource graph."); + } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResource.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResource.cs new file mode 100644 index 0000000000..5dc9f2f6ce --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResource.cs @@ -0,0 +1,11 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class UnknownResource : Identifiable + { + public string? Value { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourceControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourceControllerTests.cs new file mode 100644 index 0000000000..d2803c195c --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourceControllerTests.cs @@ -0,0 +1,32 @@ +using System; +using FluentAssertions; +using JsonApiDotNetCore.Errors; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers +{ + public sealed class UnknownResourceControllerTests : IntegrationTestContext, NonJsonApiDbContext> + { + public UnknownResourceControllerTests() + { + UseController(); + } + + [Fact] + public void Fails_at_startup_when_using_controller_for_resource_type_that_is_not_registered_in_resource_graph() + { + // Act + Action action = () => _ = Factory; + + // Assert + action.Should().ThrowExactly().WithMessage($"Controller '{typeof(UnknownResourcesController)}' " + + $"depends on resource type '{typeof(UnknownResource)}', which does not exist in the resource graph."); + } + + public override void Dispose() + { + // Prevents crash when test cleanup tries to access lazily constructed Factory. + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourcesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourcesController.cs new file mode 100644 index 0000000000..82bb597266 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/UnknownResourcesController.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers +{ + public sealed class UnknownResourcesController : JsonApiController + { + public UnknownResourcesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj index efb9e23173..742c8a7674 100644 --- a/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj +++ b/test/JsonApiDotNetCoreTests/JsonApiDotNetCoreTests.csproj @@ -18,6 +18,7 @@ + diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs new file mode 100644 index 0000000000..29b778c1e9 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs @@ -0,0 +1,363 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Castle.DynamicProxy; +using FluentAssertions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.UnitTests.ResourceGraph +{ + public sealed class ResourceGraphBuilderTests + { + [Fact] + public void Resource_without_public_name_gets_pluralized_with_naming_convention_applied() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + builder.Add(); + + // Assert + IResourceGraph resourceGraph = builder.Build(); + ResourceType resourceType = resourceGraph.GetResourceType(); + + resourceType.PublicName.Should().Be("resourceWithAttributes"); + } + + [Fact] + public void Attribute_without_public_name_gets_naming_convention_applied() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + builder.Add(); + + // Assert + IResourceGraph resourceGraph = builder.Build(); + ResourceType resourceType = resourceGraph.GetResourceType(); + + AttrAttribute attribute = resourceType.GetAttributeByPropertyName(nameof(ResourceWithAttribute.PrimaryValue)); + attribute.PublicName.Should().Be("primaryValue"); + } + + [Fact] + public void HasOne_relationship_without_public_name_gets_naming_convention_applied() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + builder.Add(); + + // Assert + IResourceGraph resourceGraph = builder.Build(); + ResourceType resourceType = resourceGraph.GetResourceType(); + + RelationshipAttribute relationship = resourceType.GetRelationshipByPropertyName(nameof(ResourceWithAttribute.PrimaryChild)); + relationship.PublicName.Should().Be("primaryChild"); + } + + [Fact] + public void HasMany_relationship_without_public_name_gets_naming_convention_applied() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + builder.Add(); + + // Assert + IResourceGraph resourceGraph = builder.Build(); + ResourceType resourceType = resourceGraph.GetResourceType(); + + RelationshipAttribute relationship = resourceType.GetRelationshipByPropertyName(nameof(ResourceWithAttribute.TopLevelChildren)); + relationship.PublicName.Should().Be("topLevelChildren"); + } + + [Fact] + public void Cannot_use_duplicate_resource_name() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + builder.Add("duplicate"); + + // Act + Action action = () => builder.Add("duplicate"); + + // Assert + action.Should().ThrowExactly().WithMessage( + $"Resource '{typeof(ResourceWithHasOneRelationship)}' and '{typeof(ResourceWithAttribute)}' both use public name 'duplicate'."); + } + + [Fact] + public void Cannot_use_duplicate_attribute_name() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + Action action = () => builder.Add(); + + // Assert + action.Should().ThrowExactly().WithMessage( + $"Properties '{typeof(ResourceWithDuplicateAttrPublicName)}.Value1' and " + + $"'{typeof(ResourceWithDuplicateAttrPublicName)}.Value2' both use public name 'duplicate'."); + } + + [Fact] + public void Cannot_use_duplicate_relationship_name() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + Action action = () => builder.Add(); + + // Assert + action.Should().ThrowExactly().WithMessage( + $"Properties '{typeof(ResourceWithDuplicateRelationshipPublicName)}.PrimaryChild' and " + + $"'{typeof(ResourceWithDuplicateRelationshipPublicName)}.Children' both use public name 'duplicate'."); + } + + [Fact] + public void Cannot_use_duplicate_attribute_and_relationship_name() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + Action action = () => builder.Add(); + + // Assert + action.Should().ThrowExactly().WithMessage( + $"Properties '{typeof(ResourceWithDuplicateAttrRelationshipPublicName)}.Value' and " + + $"'{typeof(ResourceWithDuplicateAttrRelationshipPublicName)}.Children' both use public name 'duplicate'."); + } + + [Fact] + public void Cannot_add_resource_that_implements_only_non_generic_IIdentifiable() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + // Act + Action action = () => builder.Add(typeof(ResourceWithoutId)); + + // Assert + action.Should().ThrowExactly() + .WithMessage($"Resource type '{typeof(ResourceWithoutId)}' implements 'IIdentifiable', but not 'IIdentifiable'."); + } + + [Fact] + public void Cannot_build_graph_with_missing_related_HasOne_resource() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + builder.Add(); + + // Act + Action action = () => builder.Build(); + + // Assert + action.Should().ThrowExactly().WithMessage($"Resource type '{typeof(ResourceWithHasOneRelationship)}' " + + $"depends on '{typeof(ResourceWithAttribute)}', which was not added to the resource graph."); + } + + [Fact] + public void Cannot_build_graph_with_missing_related_HasMany_resource() + { + // Arrange + var options = new JsonApiOptions(); + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + + builder.Add(); + + // Act + Action action = () => builder.Build(); + + // Assert + action.Should().ThrowExactly().WithMessage($"Resource type '{typeof(ResourceWithHasManyRelationship)}' " + + $"depends on '{typeof(ResourceWithAttribute)}', which was not added to the resource graph."); + } + + [Fact] + public void Logs_warning_when_adding_non_resource_type() + { + // Arrange + var options = new JsonApiOptions(); + var loggerFactory = new FakeLoggerFactory(LogLevel.Warning); + var builder = new ResourceGraphBuilder(options, loggerFactory); + + // Act + builder.Add(typeof(NonResource)); + + // Assert + loggerFactory.Logger.Messages.ShouldHaveCount(1); + + FakeLoggerFactory.FakeLogMessage message = loggerFactory.Logger.Messages.ElementAt(0); + message.LogLevel.Should().Be(LogLevel.Warning); + message.Text.Should().Be($"Skipping: Type '{typeof(NonResource)}' does not implement 'IIdentifiable'."); + } + + [Fact] + public void Logs_warning_when_adding_resource_without_attributes() + { + // Arrange + var options = new JsonApiOptions(); + var loggerFactory = new FakeLoggerFactory(LogLevel.Warning); + var builder = new ResourceGraphBuilder(options, loggerFactory); + + // Act + builder.Add(); + + // Assert + loggerFactory.Logger.Messages.ShouldHaveCount(1); + + FakeLoggerFactory.FakeLogMessage message = loggerFactory.Logger.Messages.ElementAt(0); + message.LogLevel.Should().Be(LogLevel.Warning); + message.Text.Should().Be($"Type '{typeof(ResourceWithHasOneRelationship)}' does not contain any attributes."); + } + + [Fact] + public void Logs_warning_on_empty_graph() + { + // Arrange + var options = new JsonApiOptions(); + var loggerFactory = new FakeLoggerFactory(LogLevel.Warning); + var builder = new ResourceGraphBuilder(options, loggerFactory); + + // Act + builder.Build(); + + // Assert + loggerFactory.Logger.Messages.ShouldHaveCount(1); + + FakeLoggerFactory.FakeLogMessage message = loggerFactory.Logger.Messages.ElementAt(0); + message.LogLevel.Should().Be(LogLevel.Warning); + message.Text.Should().Be("The resource graph is empty."); + } + + [Fact] + public void Resolves_correct_type_for_lazy_loading_proxy() + { + // Arrange + var options = new JsonApiOptions(); + + var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); + builder.Add(); + IResourceGraph resourceGraph = builder.Build(); + + var proxyGenerator = new ProxyGenerator(); + var proxy = proxyGenerator.CreateClassProxy(); + + // Act + ResourceType resourceType = resourceGraph.GetResourceType(proxy.GetType()); + + // Assert + resourceType.ClrType.Should().Be(typeof(ResourceOfInt32)); + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ResourceWithHasOneRelationship : Identifiable + { + [HasOne] + public ResourceWithAttribute? PrimaryChild { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ResourceWithHasManyRelationship : Identifiable + { + [HasMany] + public ISet Children { get; set; } = new HashSet(); + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ResourceWithAttribute : Identifiable + { + [Attr] + public string? PrimaryValue { get; set; } + + [HasOne] + public ResourceWithAttribute? PrimaryChild { get; set; } + + [HasMany] + public ISet TopLevelChildren { get; set; } = new HashSet(); + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ResourceWithDuplicateAttrPublicName : Identifiable + { + [Attr(PublicName = "duplicate")] + public string? Value1 { get; set; } + + [Attr(PublicName = "duplicate")] + public string? Value2 { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ResourceWithDuplicateRelationshipPublicName : Identifiable + { + [HasOne(PublicName = "duplicate")] + public ResourceWithHasOneRelationship? PrimaryChild { get; set; } + + [HasMany(PublicName = "duplicate")] + public ISet Children { get; set; } = new HashSet(); + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class ResourceWithDuplicateAttrRelationshipPublicName : Identifiable + { + [Attr(PublicName = "duplicate")] + public string? Value { get; set; } + + [HasMany(PublicName = "duplicate")] + public ISet Children { get; set; } = new HashSet(); + } + + private sealed class ResourceWithoutId : IIdentifiable + { + public string? StringId { get; set; } + public string? LocalId { get; set; } + } + + [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] + private sealed class NonResource + { + } + + // ReSharper disable once ClassCanBeSealed.Global + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public class ResourceOfInt32 : Identifiable + { + [Attr] + public string? StringValue { get; set; } + + [HasOne] + public ResourceOfInt32? PrimaryChild { get; set; } + + [HasMany] + public IList Children { get; set; } = new List(); + } + } +} diff --git a/test/TestBuildingBlocks/IntegrationTestContext.cs b/test/TestBuildingBlocks/IntegrationTestContext.cs index f718c9be71..084e07987e 100644 --- a/test/TestBuildingBlocks/IntegrationTestContext.cs +++ b/test/TestBuildingBlocks/IntegrationTestContext.cs @@ -111,7 +111,7 @@ private WebApplicationFactory CreateFactory() return factoryWithConfiguredContentRoot; } - public void Dispose() + public virtual void Dispose() { RunOnDatabaseAsync(async dbContext => await dbContext.Database.EnsureDeletedAsync()).Wait(); diff --git a/test/UnitTests/Builders/ResourceGraphBuilderTests.cs b/test/UnitTests/Builders/ResourceGraphBuilderTests.cs deleted file mode 100644 index fa46300fd2..0000000000 --- a/test/UnitTests/Builders/ResourceGraphBuilderTests.cs +++ /dev/null @@ -1,135 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using FluentAssertions; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit; - -namespace UnitTests.Builders -{ - public sealed class ResourceGraphBuilderTests - { - [Fact] - public void Can_Build_ResourceGraph_Using_Builder() - { - // Arrange - var services = new ServiceCollection(); - services.AddLogging(); - services.AddDbContext(); - - services.AddJsonApi(resources: builder => builder.Add("nonDbResources")); - - // Act - ServiceProvider container = services.BuildServiceProvider(); - - // Assert - var resourceGraph = container.GetRequiredService(); - - ResourceType dbResourceType = resourceGraph.GetResourceType("dbResources"); - dbResourceType.ClrType.Should().Be(typeof(DbResource)); - - ResourceType nonDbResourceType = resourceGraph.GetResourceType("nonDbResources"); - nonDbResourceType.ClrType.Should().Be(typeof(NonDbResource)); - } - - [Fact] - public void Resources_Without_Names_Specified_Will_Use_Configured_Formatter() - { - // Arrange - var builder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); - builder.Add(); - builder.Add(); - - // Act - IResourceGraph resourceGraph = builder.Build(); - - // Assert - ResourceType testResourceType = resourceGraph.GetResourceType(); - testResourceType.PublicName.Should().Be("testResources"); - } - - [Fact] - public void Attrs_Without_Names_Specified_Will_Use_Configured_Formatter() - { - // Arrange - var builder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); - builder.Add(); - builder.Add(); - - // Act - IResourceGraph resourceGraph = builder.Build(); - - // Assert - ResourceType testResourceType = resourceGraph.GetResourceType(); - testResourceType.Attributes.Should().ContainSingle(attribute => attribute.PublicName == "compoundAttribute"); - } - - [Fact] - public void Relationships_Without_Names_Specified_Will_Use_Configured_Formatter() - { - // Arrange - var builder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); - builder.Add(); - builder.Add(); - - // Act - IResourceGraph resourceGraph = builder.Build(); - - // Assert - ResourceType testResourceType = resourceGraph.GetResourceType(); - - testResourceType.Relationships.Should().HaveCount(2); - - testResourceType.Relationships.ElementAt(0).Should().BeOfType(); - testResourceType.Relationships.ElementAt(0).PublicName.Should().Be("relatedResource"); - - testResourceType.Relationships.ElementAt(1).Should().BeOfType(); - testResourceType.Relationships.ElementAt(1).PublicName.Should().Be("relatedResources"); - } - - private sealed class NonDbResource : Identifiable - { - } - - private sealed class DbResource : Identifiable - { - } - - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - private sealed class TestDbContext : DbContext - { - public DbSet DbResources => Set(); - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseInMemoryDatabase(Guid.NewGuid().ToString()); - } - } - - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class TestResource : Identifiable - { - [Attr] - public string? CompoundAttribute { get; set; } - - [HasOne] - public RelatedResource? RelatedResource { get; set; } - - [HasMany] - public ISet RelatedResources { get; set; } = new HashSet(); - } - - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class RelatedResource : Identifiable - { - [Attr] - public string? Unused { get; set; } - } - } -} diff --git a/test/UnitTests/Internal/ResourceGraphBuilderTests.cs b/test/UnitTests/Internal/ResourceGraphBuilderTests.cs deleted file mode 100644 index 9dcdf56cbe..0000000000 --- a/test/UnitTests/Internal/ResourceGraphBuilderTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System; -using System.Linq; -using Castle.DynamicProxy; -using FluentAssertions; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Resources; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using TestBuildingBlocks; -using Xunit; - -namespace UnitTests.Internal -{ - public sealed class ResourceGraphBuilderTests - { - [Fact] - public void Throws_when_adding_resource_type_that_implements_only_non_generic_IIdentifiable() - { - // Arrange - var resourceGraphBuilder = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance); - - // Act - Action action = () => resourceGraphBuilder.Add(typeof(ResourceWithoutId)); - - // Assert - action.Should().ThrowExactly() - .WithMessage($"Resource type '{typeof(ResourceWithoutId)}' implements 'IIdentifiable', but not 'IIdentifiable'."); - } - - [Fact] - public void Logs_warning_when_adding_non_resource_type() - { - // Arrange - var loggerFactory = new FakeLoggerFactory(LogLevel.Warning); - var resourceGraphBuilder = new ResourceGraphBuilder(new JsonApiOptions(), loggerFactory); - - // Act - resourceGraphBuilder.Add(typeof(NonResource)); - - // Assert - loggerFactory.Logger.Messages.ShouldHaveCount(1); - - FakeLoggerFactory.FakeLogMessage message = loggerFactory.Logger.Messages.ElementAt(0); - message.LogLevel.Should().Be(LogLevel.Warning); - message.Text.Should().Be($"Skipping: Type '{typeof(NonResource)}' does not implement 'IIdentifiable'."); - } - - [Fact] - public void Can_resolve_correct_type_for_lazy_loading_proxy() - { - // Arrange - IResourceGraph resourceGraph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).Add().Build(); - var proxyGenerator = new ProxyGenerator(); - var proxy = proxyGenerator.CreateClassProxy(); - - // Act - ResourceType resourceType = resourceGraph.GetResourceType(proxy.GetType()); - - // Assert - resourceType.ClrType.Should().Be(typeof(ResourceOfInt32)); - } - - [Fact] - public void Can_resolve_correct_type_for_resource() - { - // Arrange - IResourceGraph resourceGraph = new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).Add().Build(); - - // Act - ResourceType resourceType = resourceGraph.GetResourceType(typeof(ResourceOfInt32)); - - // Assert - resourceType.ClrType.Should().Be(typeof(ResourceOfInt32)); - } - - private sealed class ResourceWithoutId : IIdentifiable - { - public string? StringId { get; set; } - public string? LocalId { get; set; } - } - - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - private sealed class NonResource - { - } - - // ReSharper disable once ClassCanBeSealed.Global - // ReSharper disable once MemberCanBePrivate.Global - public class ResourceOfInt32 : Identifiable - { - } - } -} diff --git a/test/UnitTests/UnitTests.csproj b/test/UnitTests/UnitTests.csproj index 32694ac90f..04e37a53ce 100644 --- a/test/UnitTests/UnitTests.csproj +++ b/test/UnitTests/UnitTests.csproj @@ -16,7 +16,6 @@ -