diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 750c0476bc..08df1de510 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -51,7 +51,8 @@ jobs: - name: Setup PowerShell (Ubuntu) if: matrix.os == 'ubuntu-latest' run: | - dotnet tool install --global PowerShell + # Temporary version downgrade because .NET 8 is not installed on runner. + dotnet tool install --global PowerShell --version 7.3.10 - name: Find latest PowerShell version (Windows) if: matrix.os == 'windows-latest' shell: pwsh diff --git a/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs b/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs index 22da724ae2..bd6537b547 100644 --- a/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs +++ b/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs @@ -209,7 +209,7 @@ public void AssertIsNotClearingAnyRequiredToOneRelationships(string resourceName private static void AssertSameType(ResourceType resourceType, IIdentifiable resource) { Type declaredType = resourceType.ClrType; - Type instanceType = resource.GetType(); + Type instanceType = resource.GetClrType(); if (instanceType != declaredType) { diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs index 4e12b735c7..550faba632 100644 --- a/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs +++ b/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs @@ -436,7 +436,7 @@ private TableAccessorNode CreatePrimaryTableWithIdentityCondition(TableSourceNod private TableAccessorNode? FindRelatedTable(TableAccessorNode leftTableAccessor, RelationshipAttribute relationship) { Dictionary rightTableAccessors = _queryState.RelatedTables[leftTableAccessor]; - return rightTableAccessors.TryGetValue(relationship, out TableAccessorNode? rightTableAccessor) ? rightTableAccessor : null; + return rightTableAccessors.GetValueOrDefault(relationship); } private SelectNode ToSelect(bool isSubQuery, bool createAlias) diff --git a/src/Examples/DapperExample/TranslationToSql/DataModel/BaseDataModelService.cs b/src/Examples/DapperExample/TranslationToSql/DataModel/BaseDataModelService.cs index 607d8dc080..c964b6b3e7 100644 --- a/src/Examples/DapperExample/TranslationToSql/DataModel/BaseDataModelService.cs +++ b/src/Examples/DapperExample/TranslationToSql/DataModel/BaseDataModelService.cs @@ -139,7 +139,7 @@ private static bool IsMapped(PropertyInfo property) return null; } - PropertyInfo rightKeyProperty = rightResource.GetType().GetProperty(TableSourceNode.IdColumnName)!; + PropertyInfo rightKeyProperty = rightResource.GetClrType().GetProperty(TableSourceNode.IdColumnName)!; return rightKeyProperty.GetValue(rightResource); } @@ -150,7 +150,7 @@ private static bool IsMapped(PropertyInfo property) private static void AssertSameType(ResourceType resourceType, IIdentifiable resource) { Type declaredType = resourceType.ClrType; - Type instanceType = resource.GetType(); + Type instanceType = resource.GetClrType(); if (instanceType != declaredType) { diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs index edf67a3f8e..884b8ccd79 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs @@ -53,7 +53,7 @@ public ResourceType GetResourceType(string publicName) { ArgumentGuard.NotNull(publicName); - return _resourceTypesByPublicName.TryGetValue(publicName, out ResourceType? resourceType) ? resourceType : null; + return _resourceTypesByPublicName.GetValueOrDefault(publicName); } /// @@ -75,7 +75,7 @@ public ResourceType GetResourceType(Type resourceClrType) ArgumentGuard.NotNull(resourceClrType); Type typeToFind = IsLazyLoadingProxyForResourceType(resourceClrType) ? resourceClrType.BaseType! : resourceClrType; - return _resourceTypesByClrType.TryGetValue(typeToFind, out ResourceType? resourceType) ? resourceType : null; + return _resourceTypesByClrType.GetValueOrDefault(typeToFind); } private bool IsLazyLoadingProxyForResourceType(Type resourceClrType) diff --git a/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs b/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs index e00bbd50a8..0b20b897d1 100644 --- a/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs +++ b/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs @@ -4,6 +4,9 @@ using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Middleware; @@ -14,8 +17,105 @@ internal abstract class TraceLogWriter { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - ReferenceHandler = ReferenceHandler.Preserve + ReferenceHandler = ReferenceHandler.IgnoreCycles, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = + { + new JsonStringEnumConverter(), + new ResourceTypeInTraceJsonConverter(), + new ResourceFieldInTraceJsonConverterFactory(), + new AbstractResourceWrapperInTraceJsonConverterFactory(), + new IdentifiableInTraceJsonConverter() + } }; + + private sealed class ResourceTypeInTraceJsonConverter : JsonConverter + { + public override ResourceType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotSupportedException(); + } + + public override void Write(Utf8JsonWriter writer, ResourceType value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.PublicName); + } + } + + private sealed class ResourceFieldInTraceJsonConverterFactory : JsonConverterFactory + { + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert.IsAssignableTo(typeof(ResourceFieldAttribute)); + } + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + Type converterType = typeof(ResourceFieldInTraceJsonConverter<>).MakeGenericType(typeToConvert); + return (JsonConverter)Activator.CreateInstance(converterType)!; + } + + private sealed class ResourceFieldInTraceJsonConverter : JsonConverter + where TField : ResourceFieldAttribute + { + public override TField Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotSupportedException(); + } + + public override void Write(Utf8JsonWriter writer, TField value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.PublicName); + } + } + } + + private sealed class IdentifiableInTraceJsonConverter : JsonConverter + { + public override IIdentifiable Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotSupportedException(); + } + + public override void Write(Utf8JsonWriter writer, IIdentifiable value, JsonSerializerOptions options) + { + // Intentionally *not* calling GetClrType() because we need delegation to the wrapper converter. + Type runtimeType = value.GetType(); + + JsonSerializer.Serialize(writer, value, runtimeType, options); + } + } + + private sealed class AbstractResourceWrapperInTraceJsonConverterFactory : JsonConverterFactory + { + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert.IsAssignableTo(typeof(IAbstractResourceWrapper)); + } + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + Type converterType = typeof(AbstractResourceWrapperInTraceJsonConverter<>).MakeGenericType(typeToConvert); + return (JsonConverter)Activator.CreateInstance(converterType)!; + } + + private sealed class AbstractResourceWrapperInTraceJsonConverter : JsonConverter + where TWrapper : IAbstractResourceWrapper + { + public override TWrapper Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotSupportedException(); + } + + public override void Write(Utf8JsonWriter writer, TWrapper value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("ClrType", value.AbstractType.FullName); + writer.WriteString("StringId", value.StringId); + writer.WriteEndObject(); + } + } + } } internal sealed class TraceLogWriter : TraceLogWriter @@ -88,26 +188,12 @@ private static void WriteProperty(StringBuilder builder, PropertyInfo property, builder.Append(": "); object? value = property.GetValue(instance); - - if (value == null) - { - builder.Append("null"); - } - else if (value is string stringValue) - { - builder.Append('"'); - builder.Append(stringValue); - builder.Append('"'); - } - else - { - WriteObject(builder, value); - } + WriteObject(builder, value); } - private static void WriteObject(StringBuilder builder, object value) + private static void WriteObject(StringBuilder builder, object? value) { - if (HasToStringOverload(value.GetType())) + if (value != null && value is not string && HasToStringOverload(value.GetType())) { builder.Append(value); } @@ -118,28 +204,19 @@ private static void WriteObject(StringBuilder builder, object value) } } - private static bool HasToStringOverload(Type? type) + private static bool HasToStringOverload(Type type) { - if (type != null) - { - MethodInfo? toStringMethod = type.GetMethod("ToString", Array.Empty()); - - if (toStringMethod != null && toStringMethod.DeclaringType != typeof(object)) - { - return true; - } - } - - return false; + MethodInfo? toStringMethod = type.GetMethod("ToString", Array.Empty()); + return toStringMethod != null && toStringMethod.DeclaringType != typeof(object); } - private static string SerializeObject(object value) + private static string SerializeObject(object? value) { try { return JsonSerializer.Serialize(value, SerializerOptions); } - catch (JsonException) + catch (Exception exception) when (exception is JsonException or NotSupportedException) { // Never crash as a result of logging, this is best-effort only. return "object"; diff --git a/src/JsonApiDotNetCore/Queries/QueryLayer.cs b/src/JsonApiDotNetCore/Queries/QueryLayer.cs index 95d61fd4b8..3a755d519b 100644 --- a/src/JsonApiDotNetCore/Queries/QueryLayer.cs +++ b/src/JsonApiDotNetCore/Queries/QueryLayer.cs @@ -42,7 +42,7 @@ internal void WriteLayer(IndentingStringWriter writer, string? prefix) using (writer.Indent()) { - if (Include != null) + if (Include != null && Include.Elements.Any()) { writer.WriteLine($"{nameof(Include)}: {Include}"); } diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs index ccf33fcd2f..457f5082ef 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs @@ -1,4 +1,3 @@ -using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using JetBrains.Annotations; @@ -24,7 +23,7 @@ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializer Type objectType = typeToConvert.GetGenericArguments()[0]; Type converterType = typeof(SingleOrManyDataConverter<>).MakeGenericType(objectType); - return (JsonConverter)Activator.CreateInstance(converterType, BindingFlags.Instance | BindingFlags.Public, null, null, null)!; + return (JsonConverter)Activator.CreateInstance(converterType)!; } private sealed class SingleOrManyDataConverter : JsonObjectConverter> diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs index d59cd3d8b2..bae5abf988 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs @@ -80,9 +80,10 @@ public async Task Logs_at_error_level_on_unhandled_exception() error.Source.ShouldNotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); - loggerFactory.Logger.Messages.ShouldNotBeEmpty(); + IReadOnlyList logMessages = loggerFactory.Logger.GetMessages(); + logMessages.ShouldNotBeEmpty(); - loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Error && + logMessages.Should().ContainSingle(message => message.LogLevel == LogLevel.Error && message.Text.Contains("Simulated failure.", StringComparison.Ordinal)); } @@ -117,9 +118,10 @@ public async Task Logs_at_info_level_on_invalid_request_body() responseDocument.Errors.ShouldHaveCount(1); - loggerFactory.Logger.Messages.ShouldNotBeEmpty(); + IReadOnlyList logMessages = loggerFactory.Logger.GetMessages(); + logMessages.ShouldNotBeEmpty(); - loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Information && + logMessages.Should().ContainSingle(message => message.LogLevel == LogLevel.Information && message.Text.Contains("Failed to deserialize request body", StringComparison.Ordinal)); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs new file mode 100644 index 0000000000..d9c315513d --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs @@ -0,0 +1,318 @@ +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(); + + var loggerFactory = new FakeLoggerFactory(LogLevel.Trace); + + testContext.ConfigureLogging(options => + { + options.ClearProviders(); + options.AddProvider(loggerFactory); + options.SetMinimumLevel(LogLevel.Trace); + options.AddFilter((category, _) => category != null && category.StartsWith("JsonApiDotNetCore.", StringComparison.Ordinal)); + }); + + testContext.ConfigureServices(services => + { + services.AddSingleton(loggerFactory); + }); + } + + [Fact] + public async Task Logs_execution_flow_at_trace_level_on_operations_request() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); + + MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + existingTrack.Lyric = _fakers.Lyric.Generate(); + existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + existingTrack.Performers = _fakers.Performer.Generate(1); + + string newGenre = _fakers.MusicTrack.Generate().Genre!; + + Lyric existingLyric = _fakers.Lyric.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + Performer existingPerformer = _fakers.Performer.Generate(); + + 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 = loggerFactory.Logger.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"" + }} + }} +])", + $@"[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/CustomRoutes/ApiControllerAttributeLogTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs index dce62dec7e..949ea5da3b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs @@ -38,10 +38,10 @@ public void Logs_warning_at_startup_when_ApiControllerAttribute_found() _ = Factory; // Assert - _loggerFactory.Logger.Messages.ShouldHaveCount(1); - _loggerFactory.Logger.Messages.Single().LogLevel.Should().Be(LogLevel.Warning); + IReadOnlyList logLines = _loggerFactory.Logger.GetLines(); + logLines.ShouldHaveCount(1); - _loggerFactory.Logger.Messages.Single().Text.Should().Be( - $"Found JSON:API controller '{typeof(CiviliansController)}' with [ApiController]. Please remove this attribute for optimal JSON:API compliance."); + logLines[0].Should().Be( + $"[WARNING] Found JSON:API controller '{typeof(CiviliansController)}' with [ApiController]. Please remove this attribute for optimal JSON:API compliance."); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs index 7bf804a7d0..3641e9e23d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs @@ -80,9 +80,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Meta.Should().BeNull(); - loggerFactory.Logger.Messages.ShouldHaveCount(1); - loggerFactory.Logger.Messages.Single().LogLevel.Should().Be(LogLevel.Warning); - loggerFactory.Logger.Messages.Single().Text.Should().Contain("Article with code 'X123' is no longer available."); + IReadOnlyList logMessages = loggerFactory.Logger.GetMessages(); + logMessages.ShouldHaveCount(1); + + logMessages[0].LogLevel.Should().Be(LogLevel.Warning); + logMessages[0].Text.Should().Contain("Article with code 'X123' is no longer available."); } [Fact] @@ -123,7 +125,8 @@ public async Task Logs_and_produces_error_response_on_deserialization_failure() stackTraceLines.ShouldNotBeEmpty(); }); - loggerFactory.Logger.Messages.Should().BeEmpty(); + IReadOnlyList logMessages = loggerFactory.Logger.GetMessages(); + logMessages.Should().BeEmpty(); } [Fact] @@ -166,8 +169,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Meta.Should().BeNull(); - loggerFactory.Logger.Messages.ShouldHaveCount(1); - loggerFactory.Logger.Messages.Single().LogLevel.Should().Be(LogLevel.Error); - loggerFactory.Logger.Messages.Single().Text.Should().Contain("Exception has been thrown by the target of an invocation."); + IReadOnlyList logMessages = loggerFactory.Logger.GetMessages(); + logMessages.ShouldHaveCount(1); + + logMessages[0].LogLevel.Should().Be(LogLevel.Error); + logMessages[0].Text.Should().Contain("Exception has been thrown by the target of an invocation."); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Banana.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Banana.cs new file mode 100644 index 0000000000..6b1ad732d0 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Banana.cs @@ -0,0 +1,14 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Logging; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Logging")] +public sealed class Banana : Fruit +{ + public override string Color => "Yellow"; + + [Attr] + public double LengthInCentimeters { get; set; } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Fruit.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Fruit.cs new file mode 100644 index 0000000000..fd0fbf0dfa --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Fruit.cs @@ -0,0 +1,13 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Logging; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Logging")] +public abstract class Fruit : Identifiable +{ + [Attr] + public abstract string Color { get; } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/FruitBowl.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/FruitBowl.cs new file mode 100644 index 0000000000..15cae39fe9 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/FruitBowl.cs @@ -0,0 +1,13 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Logging; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Logging")] +public sealed class FruitBowl : Identifiable +{ + [HasMany] + public ISet Fruits { get; set; } = new HashSet(); +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingDbContext.cs index 761806d3c9..26d86c8c3f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingDbContext.cs @@ -8,6 +8,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Logging; public sealed class LoggingDbContext : TestableDbContext { public DbSet AuditEntries => Set(); + public DbSet FruitBowls => Set(); + public DbSet Fruits => Set(); + public DbSet Bananas => Set(); + public DbSet Peaches => Set(); public LoggingDbContext(DbContextOptions options) : base(options) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs index 5d2b25a74c..a52f164bf0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs @@ -13,5 +13,15 @@ internal sealed class LoggingFakers : FakerContainer .RuleFor(auditEntry => auditEntry.UserName, faker => faker.Internet.UserName()) .RuleFor(auditEntry => auditEntry.CreatedAt, faker => faker.Date.PastOffset().TruncateToWholeMilliseconds())); + private readonly Lazy> _lazyBananaFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(banana => banana.LengthInCentimeters, faker => faker.Random.Double(10, 25))); + + private readonly Lazy> _lazyPeachFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(peach => peach.DiameterInCentimeters, faker => faker.Random.Double(6, 7.5))); + public Faker AuditEntry => _lazyAuditEntryFaker.Value; + public Faker Banana => _lazyBananaFaker.Value; + public Faker Peach => _lazyPeachFaker.Value; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs index 3b92994de9..8cb79e0376 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Net; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; @@ -17,6 +18,7 @@ public LoggingTests(IntegrationTestContext, Lo _testContext = testContext; testContext.UseController(); + testContext.UseController(); var loggerFactory = new FakeLoggerFactory(LogLevel.Trace); @@ -25,6 +27,7 @@ public LoggingTests(IntegrationTestContext, Lo options.ClearProviders(); options.AddProvider(loggerFactory); options.SetMinimumLevel(LogLevel.Trace); + options.AddFilter((category, _) => category != null && category.StartsWith("JsonApiDotNetCore.", StringComparison.Ordinal)); }); testContext.ConfigureServices(services => @@ -64,10 +67,11 @@ public async Task Logs_request_body_at_Trace_level() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - loggerFactory.Logger.Messages.ShouldNotBeEmpty(); + IReadOnlyList logLines = loggerFactory.Logger.GetLines(); + logLines.ShouldNotBeEmpty(); - loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Trace && - message.Text.StartsWith("Received POST request at 'http://localhost/auditEntries' with body: <<", StringComparison.Ordinal)); + logLines.Should().ContainSingle(line => + line.StartsWith("[TRACE] Received POST request at 'http://localhost/auditEntries' with body: <<", StringComparison.Ordinal)); } [Fact] @@ -86,10 +90,11 @@ public async Task Logs_response_body_at_Trace_level() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - loggerFactory.Logger.Messages.ShouldNotBeEmpty(); + IReadOnlyList logLines = loggerFactory.Logger.GetLines(); + logLines.ShouldNotBeEmpty(); - loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Trace && - message.Text.StartsWith("Sending 200 response for GET request at 'http://localhost/auditEntries' with body: <<", StringComparison.Ordinal)); + logLines.Should().ContainSingle(line => + line.StartsWith("[TRACE] Sending 200 response for GET request at 'http://localhost/auditEntries' with body: <<", StringComparison.Ordinal)); } [Fact] @@ -110,9 +115,209 @@ public async Task Logs_invalid_request_body_error_at_Information_level() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - loggerFactory.Logger.Messages.ShouldNotBeEmpty(); + IReadOnlyList logMessages = loggerFactory.Logger.GetMessages(); + logMessages.ShouldNotBeEmpty(); - loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Information && - message.Text.Contains("Failed to deserialize request body.")); + logMessages.Should().ContainSingle(message => message.LogLevel == LogLevel.Information && message.Text.Contains("Failed to deserialize request body.")); + } + + [Fact] + public async Task Logs_method_parameters_of_abstract_resource_type_at_Trace_level() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); + + var existingBowl = new FruitBowl(); + Banana existingBanana = _fakers.Banana.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.FruitBowls.Add(existingBowl); + dbContext.Fruits.Add(existingBanana); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "fruits", + id = existingBanana.StringId + } + } + }; + + string route = $"/fruitBowls/{existingBowl.StringId}/relationships/fruits"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + IReadOnlyList logLines = loggerFactory.Logger.GetLines(); + + logLines.Should().BeEquivalentTo(new[] + { + $@"[TRACE] Received POST request at 'http://localhost/fruitBowls/{existingBowl.StringId}/relationships/fruits' with body: <<{{ + ""data"": [ + {{ + ""type"": ""fruits"", + ""id"": ""{existingBanana.StringId}"" + }} + ] +}}>>", + $@"[TRACE] Entering PostRelationshipAsync(id: {existingBowl.StringId}, relationshipName: ""fruits"", rightResourceIds: [ + {{ + ""ClrType"": ""{typeof(Fruit).FullName}"", + ""StringId"": ""{existingBanana.StringId}"" + }} +])", + $@"[TRACE] Entering AddToToManyRelationshipAsync(leftId: {existingBowl.StringId}, relationshipName: ""fruits"", rightResourceIds: [ + {{ + ""ClrType"": ""{typeof(Fruit).FullName}"", + ""StringId"": ""{existingBanana.StringId}"" + }} +])", + $@"[TRACE] Entering GetAsync(queryLayer: QueryLayer +{{ + Filter: equals(id,'{existingBanana.Id}') + Selection + {{ + FieldSelectors + {{ + id + }} + }} +}} +)", + $@"[TRACE] Entering ApplyQueryLayer(queryLayer: QueryLayer +{{ + Filter: equals(id,'{existingBanana.Id}') + Selection + {{ + FieldSelectors + {{ + id + }} + }} +}} +)", + $@"[TRACE] Entering AddToToManyRelationshipAsync(leftResource: null, leftId: {existingBowl.Id}, rightResourceIds: [ + {{ + ""Color"": ""Yellow"", + ""LengthInCentimeters"": {existingBanana.LengthInCentimeters.ToString(CultureInfo.InvariantCulture)}, + ""Id"": {existingBanana.Id}, + ""StringId"": ""{existingBanana.StringId}"" + }} +])" + }, options => options.Using(IgnoreLineEndingsComparer.Instance).WithStrictOrdering()); + } + + [Fact] + public async Task Logs_method_parameters_of_concrete_resource_type_at_Trace_level() + { + // Arrange + var loggerFactory = _testContext.Factory.Services.GetRequiredService(); + loggerFactory.Logger.Clear(); + + var existingBowl = new FruitBowl(); + Peach existingPeach = _fakers.Peach.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.FruitBowls.Add(existingBowl); + dbContext.Fruits.Add(existingPeach); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "peaches", + id = existingPeach.StringId + } + } + }; + + string route = $"/fruitBowls/{existingBowl.StringId}/relationships/fruits"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + IReadOnlyList logLines = loggerFactory.Logger.GetLines(); + + logLines.Should().BeEquivalentTo(new[] + { + $@"[TRACE] Received POST request at 'http://localhost/fruitBowls/{existingBowl.StringId}/relationships/fruits' with body: <<{{ + ""data"": [ + {{ + ""type"": ""peaches"", + ""id"": ""{existingPeach.StringId}"" + }} + ] +}}>>", + $@"[TRACE] Entering PostRelationshipAsync(id: {existingBowl.StringId}, relationshipName: ""fruits"", rightResourceIds: [ + {{ + ""Color"": ""Red/Yellow"", + ""DiameterInCentimeters"": 0, + ""Id"": {existingPeach.Id}, + ""StringId"": ""{existingPeach.StringId}"" + }} +])", + $@"[TRACE] Entering AddToToManyRelationshipAsync(leftId: {existingBowl.StringId}, relationshipName: ""fruits"", rightResourceIds: [ + {{ + ""Color"": ""Red/Yellow"", + ""DiameterInCentimeters"": 0, + ""Id"": {existingPeach.Id}, + ""StringId"": ""{existingPeach.StringId}"" + }} +])", + $@"[TRACE] Entering GetAsync(queryLayer: QueryLayer +{{ + Filter: equals(id,'{existingPeach.Id}') + Selection + {{ + FieldSelectors + {{ + id + }} + }} +}} +)", + $@"[TRACE] Entering ApplyQueryLayer(queryLayer: QueryLayer +{{ + Filter: equals(id,'{existingPeach.Id}') + Selection + {{ + FieldSelectors + {{ + id + }} + }} +}} +)", + $@"[TRACE] Entering AddToToManyRelationshipAsync(leftResource: null, leftId: {existingBowl.Id}, rightResourceIds: [ + {{ + ""Color"": ""Red/Yellow"", + ""DiameterInCentimeters"": 0, + ""Id"": {existingPeach.Id}, + ""StringId"": ""{existingPeach.StringId}"" + }} +])" + }, options => options.Using(IgnoreLineEndingsComparer.Instance).WithStrictOrdering()); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Peach.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Peach.cs new file mode 100644 index 0000000000..68d251666c --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Peach.cs @@ -0,0 +1,14 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Logging; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Logging")] +public sealed class Peach : Fruit +{ + public override string Color => "Red/Yellow"; + + [Attr] + public double DiameterInCentimeters { get; set; } +} diff --git a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs index 97a35603b3..11ae36cd72 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/ResourceGraph/ResourceGraphBuilderTests.cs @@ -263,11 +263,11 @@ public void Logs_warning_when_adding_non_resource_type() builder.Add(typeof(NonResource)); // Assert - loggerFactory.Logger.Messages.ShouldHaveCount(1); + IReadOnlyList logLines = loggerFactory.Logger.GetLines(); + logLines.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'. Add [NoResource] to suppress this warning."); + logLines[0].Should().Be( + $"[WARNING] Skipping: Type '{typeof(NonResource)}' does not implement 'IIdentifiable'. Add [NoResource] to suppress this warning."); } [Fact] @@ -282,7 +282,8 @@ public void Logs_no_warning_when_adding_non_resource_type_with_suppression() builder.Add(typeof(NonResourceWithSuppression)); // Assert - loggerFactory.Logger.Messages.Should().BeEmpty(); + IReadOnlyList logLines = loggerFactory.Logger.GetLines(); + logLines.Should().BeEmpty(); } [Fact] @@ -297,11 +298,10 @@ public void Logs_warning_when_adding_resource_without_attributes() builder.Add(); // Assert - loggerFactory.Logger.Messages.ShouldHaveCount(1); + IReadOnlyList logLines = loggerFactory.Logger.GetLines(); + logLines.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."); + logLines[0].Should().Be($"[WARNING] Type '{typeof(ResourceWithHasOneRelationship)}' does not contain any attributes."); } [Fact] @@ -316,11 +316,10 @@ public void Logs_warning_on_empty_graph() builder.Build(); // Assert - loggerFactory.Logger.Messages.ShouldHaveCount(1); + IReadOnlyList logLines = loggerFactory.Logger.GetLines(); + logLines.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."); + logLines[0].Should().Be("[WARNING] The resource graph is empty."); } [Fact] diff --git a/test/TestBuildingBlocks/FakeLogMessage.cs b/test/TestBuildingBlocks/FakeLogMessage.cs new file mode 100644 index 0000000000..8df3eebde6 --- /dev/null +++ b/test/TestBuildingBlocks/FakeLogMessage.cs @@ -0,0 +1,22 @@ +using JetBrains.Annotations; +using Microsoft.Extensions.Logging; + +namespace TestBuildingBlocks; + +[PublicAPI] +public sealed class FakeLogMessage +{ + public LogLevel LogLevel { get; } + public string Text { get; } + + public FakeLogMessage(LogLevel logLevel, string text) + { + LogLevel = logLevel; + Text = text; + } + + public override string ToString() + { + return $"[{LogLevel.ToString().ToUpperInvariant()}] {Text}"; + } +} diff --git a/test/TestBuildingBlocks/FakeLoggerFactory.cs b/test/TestBuildingBlocks/FakeLoggerFactory.cs index 1a1ac6d402..c946ede4ed 100644 --- a/test/TestBuildingBlocks/FakeLoggerFactory.cs +++ b/test/TestBuildingBlocks/FakeLoggerFactory.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using JetBrains.Annotations; using Microsoft.Extensions.Logging; @@ -30,9 +29,9 @@ public void Dispose() public sealed class FakeLogger : ILogger { private readonly LogLevel _minimumLevel; - private readonly ConcurrentBag _messages = new(); - public IReadOnlyCollection Messages => _messages; + private readonly object _lockObject = new(); + private readonly List _messages = new(); public FakeLogger(LogLevel minimumLevel) { @@ -46,7 +45,10 @@ public bool IsEnabled(LogLevel logLevel) public void Clear() { - _messages.Clear(); + lock (_lockObject) + { + _messages.Clear(); + } } public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) @@ -54,7 +56,11 @@ public void Log(LogLevel logLevel, EventId eventId, TState state, Except if (IsEnabled(logLevel)) { string message = formatter(state, exception); - _messages.Add(new FakeLogMessage(logLevel, message)); + + lock (_lockObject) + { + _messages.Add(new FakeLogMessage(logLevel, message)); + } } } @@ -64,6 +70,20 @@ public IDisposable BeginScope(TState state) return NullScope.Instance; } + public IReadOnlyList GetMessages() + { + lock (_lockObject) + { + List snapshot = _messages.ToList(); + return snapshot.AsReadOnly(); + } + } + + public IReadOnlyList GetLines() + { + return GetMessages().Select(message => message.ToString()).ToArray(); + } + private sealed class NullScope : IDisposable { public static readonly NullScope Instance = new(); @@ -77,21 +97,4 @@ public void Dispose() } } } - - public sealed class FakeLogMessage - { - public LogLevel LogLevel { get; } - public string Text { get; } - - public FakeLogMessage(LogLevel logLevel, string text) - { - LogLevel = logLevel; - Text = text; - } - - public override string ToString() - { - return $"[{LogLevel.ToString().ToUpperInvariant()}] {Text}"; - } - } } diff --git a/test/TestBuildingBlocks/IgnoreLineEndingsComparer.cs b/test/TestBuildingBlocks/IgnoreLineEndingsComparer.cs new file mode 100644 index 0000000000..2d6886e00c --- /dev/null +++ b/test/TestBuildingBlocks/IgnoreLineEndingsComparer.cs @@ -0,0 +1,36 @@ +namespace TestBuildingBlocks; + +public sealed class IgnoreLineEndingsComparer : IEqualityComparer +{ + private static readonly string[] LineSeparators = + { + "\r\n", + "\r", + "\n" + }; + + public static readonly IgnoreLineEndingsComparer Instance = new(); + + public bool Equals(string? x, string? y) + { + if (x == y) + { + return true; + } + + if (x == null || y == null) + { + return false; + } + + string[] xLines = x.Split(LineSeparators, StringSplitOptions.None); + string[] yLines = y.Split(LineSeparators, StringSplitOptions.None); + + return xLines.SequenceEqual(yLines); + } + + public int GetHashCode(string obj) + { + return obj.GetHashCode(); + } +}