From 1fba73f6900b216fc92f1896b99a5f520b0d19c9 Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Sat, 1 Nov 2025 16:34:38 +0100 Subject: [PATCH] (feature): saga tests and file logger improvements --- .../Logging/FileLoggerTests.cs | 106 ++++++++++ src/MaksIT.Core.Tests/Sagas/LocalSagaTests.cs | 196 ++++++++++++++++++ src/MaksIT.Core/Logging/FileLogger.cs | 30 ++- src/MaksIT.Core/Logging/FileLoggerProvider.cs | 10 +- .../Logging/LoggingBuilderExtensions.cs | 4 +- src/MaksIT.Core/MaksIT.Core.csproj | 2 +- 6 files changed, 336 insertions(+), 12 deletions(-) create mode 100644 src/MaksIT.Core.Tests/Logging/FileLoggerTests.cs diff --git a/src/MaksIT.Core.Tests/Logging/FileLoggerTests.cs b/src/MaksIT.Core.Tests/Logging/FileLoggerTests.cs new file mode 100644 index 0000000..b22faa2 --- /dev/null +++ b/src/MaksIT.Core.Tests/Logging/FileLoggerTests.cs @@ -0,0 +1,106 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using MaksIT.Core.Logging; + + +namespace MaksIT.Core.Tests.Logging; + +public class FileLoggerTests { + private readonly string _testFolderPath; + + public FileLoggerTests() { + _testFolderPath = Path.Combine(Path.GetTempPath(), "FileLoggerTests"); + if (Directory.Exists(_testFolderPath)) { + Directory.Delete(_testFolderPath, true); + } + Directory.CreateDirectory(_testFolderPath); + } + + [Fact] + public void ShouldWriteLogsToCorrectFile() { + // Arrange + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(sp => + new TestHostEnvironment { + EnvironmentName = Environments.Development, + ApplicationName = "TestApp", + ContentRootPath = Directory.GetCurrentDirectory() + }); + + serviceCollection.AddLogging(builder => builder.AddFile(_testFolderPath, TimeSpan.FromDays(7))); + + var provider = serviceCollection.BuildServiceProvider(); + var logger = provider.GetRequiredService>(); + + // Act + logger.LogInformation("Test log message"); + + // Assert + var logFile = Directory.GetFiles(_testFolderPath, "log_*.txt").FirstOrDefault(); + Assert.NotNull(logFile); + var logContent = File.ReadAllText(logFile); + Assert.Contains("Test log message", logContent); + } + + [Fact] + public void ShouldDeleteOldLogsBasedOnRetention() { + // Arrange + var retentionPeriod = TimeSpan.FromDays(1); + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(sp => + new TestHostEnvironment { + EnvironmentName = Environments.Development, + ApplicationName = "TestApp", + ContentRootPath = Directory.GetCurrentDirectory() + }); + + serviceCollection.AddLogging(builder => builder.AddFile(_testFolderPath, retentionPeriod)); + + var provider = serviceCollection.BuildServiceProvider(); + var logger = provider.GetRequiredService>(); + + // Create an old log file + var oldLogFile = Path.Combine(_testFolderPath, $"log_{DateTime.Now.AddDays(-2):yyyy-MM-dd}.txt"); + File.WriteAllText(oldLogFile, "Old log"); + + // Act + logger.LogInformation("New log message"); + + // Assert + Assert.False(File.Exists(oldLogFile), "Old log file should have been deleted."); + var logFile = Directory.GetFiles(_testFolderPath, "log_*.txt").FirstOrDefault(); + Assert.NotNull(logFile); + var logContent = File.ReadAllText(logFile); + Assert.Contains("New log message", logContent); + } + + [Fact] + public void ShouldHandleExceptionsGracefully() { + // Arrange + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(sp => + new TestHostEnvironment { + EnvironmentName = Environments.Development, + ApplicationName = "TestApp", + ContentRootPath = Directory.GetCurrentDirectory() + }); + + serviceCollection.AddLogging(builder => builder.AddFile(_testFolderPath)); + + var provider = serviceCollection.BuildServiceProvider(); + var logger = provider.GetRequiredService>(); + + // Act & Assert + try { + logger.LogError(new InvalidOperationException("Test exception"), "An error occurred"); + var logFile = Directory.GetFiles(_testFolderPath, "log_*.txt").FirstOrDefault(); + Assert.NotNull(logFile); + var logContent = File.ReadAllText(logFile); + Assert.Contains("An error occurred", logContent); + Assert.Contains("Test exception", logContent); + } catch { + Assert.Fail("Logger should handle exceptions gracefully."); + } + } +} diff --git a/src/MaksIT.Core.Tests/Sagas/LocalSagaTests.cs b/src/MaksIT.Core.Tests/Sagas/LocalSagaTests.cs index 5f36658..d534e21 100644 --- a/src/MaksIT.Core.Tests/Sagas/LocalSagaTests.cs +++ b/src/MaksIT.Core.Tests/Sagas/LocalSagaTests.cs @@ -187,4 +187,200 @@ public class LocalSagaTests Assert.Contains("Step2 compensated", compensationLog); Assert.Contains("Step1 compensated", compensationLog); } + + [Fact] + public async Task LocalSagaBuilder_ShouldBuildSagaWithStepsAndReturnResult() + { + // Arrange + var logger = LoggerHelper.CreateConsoleLogger(); + var builder = new LocalSagaBuilder(logger); + var stepResult = ""; + + builder.AddStep( + "TestStep", + async (ctx, ct) => + { + await Task.CompletedTask; + return "StepResult"; + }, + outputKey: "stepResult"); + + var saga = builder.Build(); + var context = new LocalSagaContext(); + + // Act + await saga.ExecuteAsync(context); + stepResult = context.Get("stepResult"); + + // Assert + Assert.Equal("StepResult", stepResult); + } + + [Fact] + public async Task LocalSagaBuilder_ShouldSkipConditionalStep() + { + // Arrange + var logger = LoggerHelper.CreateConsoleLogger(); + var builder = new LocalSagaBuilder(logger); + var stepExecuted = false; + + builder.AddStepIf( + ctx => false, + "SkippedStep", + async (ctx, ct) => + { + stepExecuted = true; + return "Skipped"; + }, + outputKey: "skippedResult"); + + var saga = builder.Build(); + + // Act + await saga.ExecuteAsync(); + + // Assert + Assert.False(stepExecuted, "The step should have been skipped."); + } + + [Fact] + public async Task LocalSagaBuilder_ShouldCompensateStepOnFailure() + { + // Arrange + var logger = LoggerHelper.CreateConsoleLogger(); + var builder = new LocalSagaBuilder(logger); + var compensationCalled = false; + + builder.AddStep( + "FailingStep", + async (ctx, ct) => + { + throw new InvalidOperationException("Step failed"); + }, + outputKey: "failingResult", + compensate: async (ctx, ct) => + { + compensationCalled = true; + await Task.CompletedTask; + }); + + var saga = builder.Build(); + + // Act & Assert + await Assert.ThrowsAsync(() => saga.ExecuteAsync()); + Assert.True(compensationCalled, "Compensation should have been called."); + } + + [Fact] + public async Task LocalSagaBuilder_ShouldStoreStepOutputInContext() + { + // Arrange + var logger = LoggerHelper.CreateConsoleLogger(); + var builder = new LocalSagaBuilder(logger); + var context = new LocalSagaContext(); + + builder.AddStep( + "OutputStep", + async (ctx, ct) => + { + await Task.CompletedTask; + return 42; + }, + outputKey: "result"); + + var saga = builder.Build(); + + // Act + await saga.ExecuteAsync(context); + var result = context.Get("result"); + + // Assert + Assert.Equal(42, result); + } + + [Fact] + public async Task LocalSagaBuilder_ShouldSaveBackupValueAndRestoreOnError() + { + // Arrange + var logger = LoggerHelper.CreateConsoleLogger(); + var builder = new LocalSagaBuilder(logger); + var context = new LocalSagaContext(); + context.Set("state", "initial"); + + builder.AddStep( + "ModifyStateStep", + async (ctx, ct) => + { + // Save the original value to a backup key + var originalState = ctx.Get("state"); + ctx.Set("backup_state", originalState); + + // Modify the state + ctx.Set("state", "modified"); + await Task.CompletedTask; + return "modified"; + }, + outputKey: "modifyStateResult", + compensate: async (ctx, ct) => + { + // Restore the original value from the backup key + var backupState = ctx.Get("backup_state"); + ctx.Set("state", backupState); + await Task.CompletedTask; + }); + + builder.AddStep( + "FailingStep", + async (ctx, ct) => + { + throw new InvalidOperationException("Step failed"); + }, + outputKey: "failingResult"); + + var saga = builder.Build(); + + // Act & Assert + await Assert.ThrowsAsync(() => saga.ExecuteAsync(context)); + Assert.Equal("initial", context.Get("state")); + } + + [Fact] + public async Task LocalSagaBuilder_ShouldUseOutputKeyToStoreAndRetrieveValues() + { + // Arrange + var logger = LoggerHelper.CreateConsoleLogger(); + var builder = new LocalSagaBuilder(logger); + var context = new LocalSagaContext(); + + builder.AddStep( + "Step1", + async (ctx, ct) => + { + await Task.CompletedTask; + return 100; + }, + outputKey: "step1Result"); + + builder.AddStep( + "Step2", + async (ctx, ct) => + { + var step1Result = ctx.Get("step1Result"); + await Task.CompletedTask; + return step1Result + 50; + }, + outputKey: "step2Result"); + + var saga = builder.Build(); + + // Act + await saga.ExecuteAsync(context); + + // Assert + var step1Result = context.Get("step1Result"); + var step2Result = context.Get("step2Result"); + + Assert.Equal(100, step1Result); + Assert.Equal(150, step2Result); + } } diff --git a/src/MaksIT.Core/Logging/FileLogger.cs b/src/MaksIT.Core/Logging/FileLogger.cs index e7eb61b..8ad8861 100644 --- a/src/MaksIT.Core/Logging/FileLogger.cs +++ b/src/MaksIT.Core/Logging/FileLogger.cs @@ -1,14 +1,17 @@ using Microsoft.Extensions.Logging; - +using System.IO; namespace MaksIT.Core.Logging; public class FileLogger : ILogger { - private readonly string _filePath; + private readonly string _folderPath; private readonly object _lock = new object(); + private readonly TimeSpan _retentionPeriod; - public FileLogger(string filePath) { - _filePath = filePath; + public FileLogger(string folderPath, TimeSpan retentionPeriod) { + _folderPath = folderPath; + _retentionPeriod = retentionPeriod; + Directory.CreateDirectory(_folderPath); // Ensure the folder exists } public IDisposable? BeginScope(TState state) where TState : notnull => null; @@ -30,8 +33,25 @@ public class FileLogger : ILogger { logRecord += Environment.NewLine + exception; } + var logFileName = Path.Combine(_folderPath, $"log_{DateTime.Now:yyyy-MM-dd}.txt"); // Generate log file name by date + lock (_lock) { - File.AppendAllText(_filePath, logRecord + Environment.NewLine); + File.AppendAllText(logFileName, logRecord + Environment.NewLine); + CleanUpOldLogs(); + } + } + + private void CleanUpOldLogs() { + var logFiles = Directory.GetFiles(_folderPath, "log_*.txt"); + var expirationDate = DateTime.Now - _retentionPeriod; + + foreach (var logFile in logFiles) { + var fileName = Path.GetFileNameWithoutExtension(logFile); + if (DateTime.TryParseExact(fileName.Substring(4), "yyyy-MM-dd", null, System.Globalization.DateTimeStyles.None, out var logDate)) { + if (logDate < expirationDate) { + File.Delete(logFile); + } + } } } } diff --git a/src/MaksIT.Core/Logging/FileLoggerProvider.cs b/src/MaksIT.Core/Logging/FileLoggerProvider.cs index 6e2e900..600a5ec 100644 --- a/src/MaksIT.Core/Logging/FileLoggerProvider.cs +++ b/src/MaksIT.Core/Logging/FileLoggerProvider.cs @@ -9,14 +9,16 @@ namespace MaksIT.Core.Logging; [ProviderAlias("FileLogger")] public class FileLoggerProvider : ILoggerProvider { - private readonly string _filePath; + private readonly string _folderPath; + private readonly TimeSpan _retentionPeriod; - public FileLoggerProvider(string filePath) { - _filePath = filePath ?? throw new ArgumentNullException(nameof(filePath)); + public FileLoggerProvider(string folderPath, TimeSpan? retentionPeriod = null) { + _folderPath = folderPath ?? throw new ArgumentNullException(nameof(folderPath)); + _retentionPeriod = retentionPeriod ?? TimeSpan.FromDays(7); // Default retention period is 7 days } public ILogger CreateLogger(string categoryName) { - return new FileLogger(_filePath); + return new FileLogger(_folderPath, _retentionPeriod); } public void Dispose() { } diff --git a/src/MaksIT.Core/Logging/LoggingBuilderExtensions.cs b/src/MaksIT.Core/Logging/LoggingBuilderExtensions.cs index 4dd97da..e54bc4b 100644 --- a/src/MaksIT.Core/Logging/LoggingBuilderExtensions.cs +++ b/src/MaksIT.Core/Logging/LoggingBuilderExtensions.cs @@ -5,8 +5,8 @@ using Microsoft.Extensions.Hosting; namespace MaksIT.Core.Logging; public static class LoggingBuilderExtensions { - public static ILoggingBuilder AddFile(this ILoggingBuilder builder, string filePath) { - builder.Services.AddSingleton(new FileLoggerProvider(filePath)); + public static ILoggingBuilder AddFile(this ILoggingBuilder builder, string folderPath, TimeSpan? retentionPeriod = null) { + builder.Services.AddSingleton(new FileLoggerProvider(folderPath, retentionPeriod)); return builder; } public static ILoggingBuilder AddConsole(this ILoggingBuilder logging, IHostEnvironment env) { diff --git a/src/MaksIT.Core/MaksIT.Core.csproj b/src/MaksIT.Core/MaksIT.Core.csproj index 80d3221..875ce90 100644 --- a/src/MaksIT.Core/MaksIT.Core.csproj +++ b/src/MaksIT.Core/MaksIT.Core.csproj @@ -8,7 +8,7 @@ MaksIT.Core - 1.4.9 + 1.5.0 Maksym Sadovnychyy MAKS-IT MaksIT.Core