From c2d7d610f3162ebff47d05391619757ee9058017 Mon Sep 17 00:00:00 2001 From: Simon Jefferies Date: Mon, 15 Dec 2025 16:36:33 +0000 Subject: [PATCH 1/3] Add test project and improve logging error handling - Move main project to src/ and add tests/FluentLoggerExtensions.Tests - Wrap CallerLogContextExtensions logging calls in try-catch to log errors on template/argument mismatch - Add xUnit/Moq tests for all logging methods to verify robustness - Add LoggerMoqExtensions helper for verifying log output in tests --- FluentLoggerExtensions.slnx | 3 +- .../CallerLogContext.cs | 0 .../CallerLogContextExtensions.cs | 72 ++++++++++++++++-- .../FluentLoggerExtensions.cs | 0 .../FluentLoggerExtensions.csproj | 0 .../CallerLogContextLogDebugTests.cs | 73 +++++++++++++++++++ .../CallerLogContextLogErrorTests.cs | 73 +++++++++++++++++++ .../CallerLogContextLogInformationTests.cs | 73 +++++++++++++++++++ .../CallerLogContextLogWarningTests.cs | 73 +++++++++++++++++++ .../FluentLoggerExtensions.Tests.csproj | 34 +++++++++ .../LoggerMoqExtensions.cs | 21 ++++++ 11 files changed, 413 insertions(+), 9 deletions(-) rename CallerLogContext.cs => src/CallerLogContext.cs (100%) rename CallerLogContextExtensions.cs => src/CallerLogContextExtensions.cs (53%) rename FluentLoggerExtensions.cs => src/FluentLoggerExtensions.cs (100%) rename FluentLoggerExtensions.csproj => src/FluentLoggerExtensions.csproj (100%) create mode 100644 tests/FluentLoggerExtensions.Tests/CallerLogContextLogDebugTests.cs create mode 100644 tests/FluentLoggerExtensions.Tests/CallerLogContextLogErrorTests.cs create mode 100644 tests/FluentLoggerExtensions.Tests/CallerLogContextLogInformationTests.cs create mode 100644 tests/FluentLoggerExtensions.Tests/CallerLogContextLogWarningTests.cs create mode 100644 tests/FluentLoggerExtensions.Tests/FluentLoggerExtensions.Tests.csproj create mode 100644 tests/FluentLoggerExtensions.Tests/LoggerMoqExtensions.cs diff --git a/FluentLoggerExtensions.slnx b/FluentLoggerExtensions.slnx index 150dff5..741dd7e 100644 --- a/FluentLoggerExtensions.slnx +++ b/FluentLoggerExtensions.slnx @@ -2,5 +2,6 @@ - + + diff --git a/CallerLogContext.cs b/src/CallerLogContext.cs similarity index 100% rename from CallerLogContext.cs rename to src/CallerLogContext.cs diff --git a/CallerLogContextExtensions.cs b/src/CallerLogContextExtensions.cs similarity index 53% rename from CallerLogContextExtensions.cs rename to src/CallerLogContextExtensions.cs index 35547ca..3fd42c7 100644 --- a/CallerLogContextExtensions.cs +++ b/src/CallerLogContextExtensions.cs @@ -15,8 +15,22 @@ public static class CallerLogContextExtensions /// The arguments for the message. public static void LogInformation(this CallerLogContext context, string message, params object?[] args) { - string? fileName = Path.GetFileName(context.File); - context.Logger.LogInformation("[{File}:{Member}:{Line}] " + message, PrependCallerArgs(fileName, context.Member, context.Line, args)); + string fileName = Path.GetFileName(context.File); + + try + { + context.Logger.LogInformation( + "[{File}:{Member}:{Line}] " + message, + PrependCallerArgs(fileName, context.Member, context.Line, args)); + } + catch (Exception ex) + { + // Fall back to a safe message; don't rethrow + context.Logger.LogError( + ex, + "[{File}:{Member}:{Line}] Logging failed for template. Template={Template}", + fileName, context.Member, context.Line, message); + } } /// @@ -27,8 +41,22 @@ public static void LogInformation(this CallerLogContext context, string message, /// The arguments for the message. public static void LogError(this CallerLogContext context, string message, params object?[] args) { - string? fileName = Path.GetFileName(context.File); - context.Logger.LogError("[{File}:{Member}:{Line}] " + message, PrependCallerArgs(fileName, context.Member, context.Line, args)); + string fileName = Path.GetFileName(context.File); + + try + { + context.Logger.LogError( + "[{File}:{Member}:{Line}] " + message, + PrependCallerArgs(fileName, context.Member, context.Line, args)); + } + catch (Exception ex) + { + // Fall back to a safe message; don't rethrow + context.Logger.LogError( + ex, + "[{File}:{Member}:{Line}] Logging failed for template. Template={Template}", + fileName, context.Member, context.Line, message); + } } /// @@ -39,8 +67,22 @@ public static void LogError(this CallerLogContext context, string message, param /// The arguments for the message. public static void LogDebug(this CallerLogContext context, string message, params object?[] args) { - string? fileName = Path.GetFileName(context.File); - context.Logger.LogDebug("[{File}:{Member}:{Line}] " + message, PrependCallerArgs(fileName, context.Member, context.Line, args)); + string fileName = Path.GetFileName(context.File); + + try + { + context.Logger.LogDebug( + "[{File}:{Member}:{Line}] " + message, + PrependCallerArgs(fileName, context.Member, context.Line, args)); + } + catch (Exception ex) + { + // Fall back to a safe message; don't rethrow + context.Logger.LogDebug( + ex, + "[{File}:{Member}:{Line}] Logging failed for template. Template={Template}", + fileName, context.Member, context.Line, message); + } } /// @@ -51,8 +93,22 @@ public static void LogDebug(this CallerLogContext context, string message, param /// The arguments for the message. public static void LogWarning(this CallerLogContext context, string message, params object?[] args) { - string? fileName = Path.GetFileName(context.File); - context.Logger.LogWarning("[{File}:{Member}:{Line}] " + message, PrependCallerArgs(fileName, context.Member, context.Line, args)); + string fileName = Path.GetFileName(context.File); + + try + { + context.Logger.LogWarning( + "[{File}:{Member}:{Line}] " + message, + PrependCallerArgs(fileName, context.Member, context.Line, args)); + } + catch (Exception ex) + { + // Fall back to a safe message; don't rethrow + context.Logger.LogWarning( + ex, + "[{File}:{Member}:{Line}] Logging failed for template. Template={Template}", + fileName, context.Member, context.Line, message); + } } /// diff --git a/FluentLoggerExtensions.cs b/src/FluentLoggerExtensions.cs similarity index 100% rename from FluentLoggerExtensions.cs rename to src/FluentLoggerExtensions.cs diff --git a/FluentLoggerExtensions.csproj b/src/FluentLoggerExtensions.csproj similarity index 100% rename from FluentLoggerExtensions.csproj rename to src/FluentLoggerExtensions.csproj diff --git a/tests/FluentLoggerExtensions.Tests/CallerLogContextLogDebugTests.cs b/tests/FluentLoggerExtensions.Tests/CallerLogContextLogDebugTests.cs new file mode 100644 index 0000000..7f0cfcf --- /dev/null +++ b/tests/FluentLoggerExtensions.Tests/CallerLogContextLogDebugTests.cs @@ -0,0 +1,73 @@ +using FluentLogger; +using Microsoft.Extensions.Logging; +using Moq; + +namespace FluentLoggerExtensions.Tests; + +public class CallerLogContextLogDebugTests +{ + [Theory] + [InlineData("This is a test log message")] + [InlineData("This is another test log message")] + [InlineData("This is a really log test message")] + public void LogDebug_WithPlainMessage_DoesNotThrow(string message) + { + Mock logger = new(); + logger.Object.WithCaller().LogDebug(message); + logger.VerifyLogContains(message, LogLevel.Debug); + } + + [Theory] + [InlineData("hello")] + public void LogDebug_WithInterpolatedString_DoesNotThrow(string id) + { + Mock logger = new(); + + logger.Object.WithCaller().LogDebug($"{id}"); + } + + [Theory] + [InlineData("hello", "there")] + public void LogDebug_WithMatchingPlaceholdersAndArguments_DoesNotThrow(string id, string id2) + { + Mock logger = new(); + logger.Object.WithCaller().LogDebug("{FirstWord} {SecondWord}", id, id2); + } + + [Theory] + [InlineData("hello")] + public void LogDebug_WithMissingArguments_DoesNotThrowUntilStateIsEnumerated(string id) + { + Mock logger = new(); + logger.Object.WithCaller().LogDebug("{FirstWord} {SecondWord}", id); + } + + [Fact] + public void Mismatched_placeholders_throw_when_state_is_enumerated() + { + var logger = new Mock(); + + logger + .Setup(l => l.Log( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny())) + .Callback(new InvocationAction(invocation => + { + // state is argument #2 + var state = invocation.Arguments[2]; + + // This is what Serilog does internally: enumerate structured values + foreach (var _ in (IEnumerable>)state) + { + // enumeration triggers LogValuesFormatter.GetValue(...) + } + })); + + // 2 placeholders, 1 arg -> will throw once enumerated + logger.Object.WithCaller().LogDebug("{FirstWord} {SecondWord}", "hello"); + } + +} diff --git a/tests/FluentLoggerExtensions.Tests/CallerLogContextLogErrorTests.cs b/tests/FluentLoggerExtensions.Tests/CallerLogContextLogErrorTests.cs new file mode 100644 index 0000000..e6c8252 --- /dev/null +++ b/tests/FluentLoggerExtensions.Tests/CallerLogContextLogErrorTests.cs @@ -0,0 +1,73 @@ +using FluentLogger; +using Microsoft.Extensions.Logging; +using Moq; + +namespace FluentLoggerExtensions.Tests; + +public class CallerLogContextLogErrorTests +{ + [Theory] + [InlineData("This is a test log message")] + [InlineData("This is another test log message")] + [InlineData("This is a really log test message")] + public void LogError_WithPlainMessage_DoesNotThrow(string message) + { + Mock logger = new(); + logger.Object.WithCaller().LogError(message); + logger.VerifyLogContains(message, LogLevel.Error); + } + + [Theory] + [InlineData("hello")] + public void LogError_WithInterpolatedString_DoesNotThrow(string id) + { + Mock logger = new(); + + logger.Object.WithCaller().LogError($"{id}"); + } + + [Theory] + [InlineData("hello", "there")] + public void LogError_WithMatchingPlaceholdersAndArguments_DoesNotThrow(string id, string id2) + { + Mock logger = new(); + logger.Object.WithCaller().LogError("{FirstWord} {SecondWord}", id, id2); + } + + [Theory] + [InlineData("hello")] + public void LogError_WithMissingArguments_DoesNotThrowUntilStateIsEnumerated(string id) + { + Mock logger = new(); + logger.Object.WithCaller().LogError("{FirstWord} {SecondWord}", id); + } + + [Fact] + public void Mismatched_placeholders_throw_when_state_is_enumerated() + { + var logger = new Mock(); + + logger + .Setup(l => l.Log( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny())) + .Callback(new InvocationAction(invocation => + { + // state is argument #2 + var state = invocation.Arguments[2]; + + // This is what Serilog does internally: enumerate structured values + foreach (var _ in (IEnumerable>)state) + { + // enumeration triggers LogValuesFormatter.GetValue(...) + } + })); + + // 2 placeholders, 1 arg -> will throw once enumerated + logger.Object.WithCaller().LogError("{FirstWord} {SecondWord}", "hello"); + } + +} diff --git a/tests/FluentLoggerExtensions.Tests/CallerLogContextLogInformationTests.cs b/tests/FluentLoggerExtensions.Tests/CallerLogContextLogInformationTests.cs new file mode 100644 index 0000000..190d8b5 --- /dev/null +++ b/tests/FluentLoggerExtensions.Tests/CallerLogContextLogInformationTests.cs @@ -0,0 +1,73 @@ +using FluentLogger; +using Microsoft.Extensions.Logging; +using Moq; + +namespace FluentLoggerExtensions.Tests; + +public class CallerLogContextLogInformationTests +{ + [Theory] + [InlineData("This is a test log message")] + [InlineData("This is another test log message")] + [InlineData("This is a really log test message")] + public void LogInformation_WithPlainMessage_DoesNotThrow(string message) + { + Mock logger = new(); + logger.Object.WithCaller().LogInformation(message); + logger.VerifyLogContains(message); + } + + [Theory] + [InlineData("hello")] + public void LogInformation_WithInterpolatedString_DoesNotThrow(string id) + { + Mock logger = new(); + + logger.Object.WithCaller().LogInformation($"{id}"); + } + + [Theory] + [InlineData("hello", "there")] + public void LogInformation_WithMatchingPlaceholdersAndArguments_DoesNotThrow(string id, string id2) + { + Mock logger = new(); + logger.Object.WithCaller().LogInformation("{FirstWord} {SecondWord}", id, id2); + } + + [Theory] + [InlineData("hello")] + public void LogInformation_WithMissingArguments_DoesNotThrowUntilStateIsEnumerated(string id) + { + Mock logger = new(); + logger.Object.WithCaller().LogInformation("{FirstWord} {SecondWord}", id); + } + + [Fact] + public void Mismatched_placeholders_throw_when_state_is_enumerated() + { + var logger = new Mock(); + + logger + .Setup(l => l.Log( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny())) + .Callback(new InvocationAction(invocation => + { + // state is argument #2 + var state = invocation.Arguments[2]; + + // This is what Serilog does internally: enumerate structured values + foreach (var _ in (IEnumerable>)state) + { + // enumeration triggers LogValuesFormatter.GetValue(...) + } + })); + + // 2 placeholders, 1 arg -> will throw once enumerated + logger.Object.WithCaller().LogInformation("{FirstWord} {SecondWord}", "hello"); + } + +} diff --git a/tests/FluentLoggerExtensions.Tests/CallerLogContextLogWarningTests.cs b/tests/FluentLoggerExtensions.Tests/CallerLogContextLogWarningTests.cs new file mode 100644 index 0000000..abaa4cc --- /dev/null +++ b/tests/FluentLoggerExtensions.Tests/CallerLogContextLogWarningTests.cs @@ -0,0 +1,73 @@ +using FluentLogger; +using Microsoft.Extensions.Logging; +using Moq; + +namespace FluentLoggerExtensions.Tests; + +public class CallerLogContextLogWarningTests +{ + [Theory] + [InlineData("This is a test log message")] + [InlineData("This is another test log message")] + [InlineData("This is a really log test message")] + public void LogWarning_WithPlainMessage_DoesNotThrow(string message) + { + Mock logger = new(); + logger.Object.WithCaller().LogWarning(message); + logger.VerifyLogContains(message, LogLevel.Warning); + } + + [Theory] + [InlineData("hello")] + public void LogWarning_WithInterpolatedString_DoesNotThrow(string id) + { + Mock logger = new(); + + logger.Object.WithCaller().LogWarning($"{id}"); + } + + [Theory] + [InlineData("hello", "there")] + public void LogWarning_WithMatchingPlaceholdersAndArguments_DoesNotThrow(string id, string id2) + { + Mock logger = new(); + logger.Object.WithCaller().LogWarning("{FirstWord} {SecondWord}", id, id2); + } + + [Theory] + [InlineData("hello")] + public void LogWarning_WithMissingArguments_DoesNotThrowUntilStateIsEnumerated(string id) + { + Mock logger = new(); + logger.Object.WithCaller().LogWarning("{FirstWord} {SecondWord}", id); + } + + [Fact] + public void Mismatched_placeholders_throw_when_state_is_enumerated() + { + var logger = new Mock(); + + logger + .Setup(l => l.Log( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + (Func)It.IsAny())) + .Callback(new InvocationAction(invocation => + { + // state is argument #2 + var state = invocation.Arguments[2]; + + // This is what Serilog does internally: enumerate structured values + foreach (var _ in (IEnumerable>)state) + { + // enumeration triggers LogValuesFormatter.GetValue(...) + } + })); + + // 2 placeholders, 1 arg -> will throw once enumerated + logger.Object.WithCaller().LogWarning("{FirstWord} {SecondWord}", "hello"); + } + +} diff --git a/tests/FluentLoggerExtensions.Tests/FluentLoggerExtensions.Tests.csproj b/tests/FluentLoggerExtensions.Tests/FluentLoggerExtensions.Tests.csproj new file mode 100644 index 0000000..c7753e3 --- /dev/null +++ b/tests/FluentLoggerExtensions.Tests/FluentLoggerExtensions.Tests.csproj @@ -0,0 +1,34 @@ + + + + net8.0 + enable + enable + + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/tests/FluentLoggerExtensions.Tests/LoggerMoqExtensions.cs b/tests/FluentLoggerExtensions.Tests/LoggerMoqExtensions.cs new file mode 100644 index 0000000..bb31bd8 --- /dev/null +++ b/tests/FluentLoggerExtensions.Tests/LoggerMoqExtensions.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.Logging; +using Moq; + +namespace FluentLoggerExtensions.Tests; + +public static class LoggerMoqExtensions +{ + public static void VerifyLogContains(this Mock logger, string message, LogLevel level = LogLevel.Information, Times? times = null) + { + logger.Verify(x => + x.Log( + level, + It.IsAny(), + It.Is((v, t) => + v.ToString()!.Contains(message) + ), + It.IsAny(), + It.IsAny>()), + Times.Once); + } +} From 66a3dbc94ce7ed6f249a6f7751ae7f24ed842e9b Mon Sep 17 00:00:00 2001 From: Simon Jefferies Date: Mon, 15 Dec 2025 16:44:52 +0000 Subject: [PATCH 2/3] Add test step and bumping/tagging of version. --- .github/workflows/dotnet-desktop.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/dotnet-desktop.yml b/.github/workflows/dotnet-desktop.yml index a4ed437..0a5a46d 100644 --- a/.github/workflows/dotnet-desktop.yml +++ b/.github/workflows/dotnet-desktop.yml @@ -28,9 +28,19 @@ jobs: - name: Restore dependencies run: dotnet restore + - name: Bump & push tag + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + uses: mathieudutour/github-tag-action@v6.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + tag_prefix: "" + - name: Build run: dotnet build --configuration Release --no-restore + - name: Test + run: dotnet test --configuration Release --no-build + - name: Publish Nuget shell: pwsh run: | From 4d5847f2f139ef25cc25216b018c71278f34c646 Mon Sep 17 00:00:00 2001 From: Simon Jefferies Date: Mon, 15 Dec 2025 16:47:54 +0000 Subject: [PATCH 3/3] Add README.md to package. --- FluentLoggerExtensions.slnx | 1 + src/FluentLoggerExtensions.csproj | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/FluentLoggerExtensions.slnx b/FluentLoggerExtensions.slnx index 741dd7e..110d30d 100644 --- a/FluentLoggerExtensions.slnx +++ b/FluentLoggerExtensions.slnx @@ -1,6 +1,7 @@ + diff --git a/src/FluentLoggerExtensions.csproj b/src/FluentLoggerExtensions.csproj index c8f6846..97813f0 100644 --- a/src/FluentLoggerExtensions.csproj +++ b/src/FluentLoggerExtensions.csproj @@ -13,6 +13,7 @@ MIT true snupkg + README.md @@ -23,4 +24,8 @@ + + + +