namespace HrynCo.Common.Tests; using System.Collections.Concurrent; using HrynCo.Common; using FluentAssertions; using Serilog; using Serilog.Core; using Serilog.Events; using Xunit; public sealed class ProfilerTests { [Fact] public async Task MeasureExecutionAsync_ShouldReturnResultAndWriteStartAndEndEvents() { var sink = new CollectingSink(); ILogger logger = new LoggerConfiguration() .MinimumLevel.Verbose() .WriteTo.Sink(sink) .CreateLogger(); var profiler = new Profiler(logger); int result = await profiler.MeasureExecutionAsync(async () => { await Task.Delay(1); return 42; }, "LoadItems"); result.Should().Be(42); sink.Events.Count.Should().BeGreaterThan(1); sink.Events.Should().ContainSingle(e => e.Level == LogEventLevel.Information && e.MessageTemplate.Text.Contains("Start", StringComparison.Ordinal) && e.Properties["BlockName"].ToString().Contains("LoadItems", StringComparison.Ordinal)); sink.Events.Should().ContainSingle(e => e.Level == LogEventLevel.Information && e.MessageTemplate.Text.Contains("End", StringComparison.Ordinal) && e.Properties["Measurements"].ToString().Contains("True", StringComparison.Ordinal)); } [Fact] public async Task MeasureExecutionAsync_ActionOverload_ShouldInvokeAction() { var sink = new CollectingSink(); ILogger logger = new LoggerConfiguration() .MinimumLevel.Verbose() .WriteTo.Sink(sink) .CreateLogger(); var profiler = new Profiler(logger); bool invoked = false; await profiler.MeasureExecutionAsync(async () => { invoked = true; await Task.CompletedTask; }, "ActionBlock"); invoked.Should().BeTrue(); } [Fact] public async Task MeasureExecutionAsync_ShouldLogErrorAndRethrow() { var sink = new CollectingSink(); ILogger logger = new LoggerConfiguration() .MinimumLevel.Verbose() .WriteTo.Sink(sink) .CreateLogger(); var profiler = new Profiler(logger); Func act = async () => { await profiler.MeasureExecutionAsync(() => throw new InvalidOperationException("boom"), "FailBlock"); }; await act.Should().ThrowAsync(); sink.Events.Should().Contain(e => e.Level == LogEventLevel.Error && e.MessageTemplate.Text.Contains("An error occurred", StringComparison.Ordinal)); } [Theory] [InlineData(false)] [InlineData(true)] public async Task MeasureExecutionAsync_WithCanceledToken_ShouldLogCancellationWithoutExceptionAndRethrow(bool actionOverload) { var sink = new CollectingSink(); IProfiler profiler = CreateProfiler(sink); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); var exception = new OperationCanceledException(cancellation.Token); Func act = () => MeasureFailureAsync(profiler, actionOverload, exception, cancellation.Token); var assertion = await act.Should().ThrowAsync(); assertion.Which.Should().BeSameAs(exception); sink.Events.Should().ContainSingle(e => e.Level == LogEventLevel.Debug && e.Exception == null && e.MessageTemplate.Text == "{BlockName} - Canceled" && e.Properties["BlockName"].ToString() == "\"CanceledBlock\"" && e.Properties["PerformanceLog"].ToString() == "True"); sink.Events.Should().NotContain(e => e.Level == LogEventLevel.Error || e.Properties.ContainsKey("Measurements")); } [Theory] [InlineData(false)] [InlineData(true)] public async Task MeasureExecutionAsync_WithCanceledToken_ShouldRecognizeTaskCanceledException(bool actionOverload) { var sink = new CollectingSink(); IProfiler profiler = CreateProfiler(sink); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); var exception = new TaskCanceledException(); Func act = () => MeasureFailureAsync(profiler, actionOverload, exception, cancellation.Token); var assertion = await act.Should().ThrowAsync(); assertion.Which.Should().BeSameAs(exception); sink.Events.Should().ContainSingle(e => e.Level == LogEventLevel.Debug && e.Exception == null); sink.Events.Should().NotContain(e => e.Level == LogEventLevel.Error || e.Properties.ContainsKey("Measurements")); } [Theory] [InlineData(false, false)] [InlineData(false, true)] [InlineData(true, false)] [InlineData(true, true)] public async Task MeasureExecutionAsync_WithUncanceledToken_ShouldLogOperationCanceledExceptionAsError(bool actionOverload, bool tokenCanBeCanceled) { var sink = new CollectingSink(); IProfiler profiler = CreateProfiler(sink); using var cancellation = new CancellationTokenSource(); CancellationToken token = tokenCanBeCanceled ? cancellation.Token : CancellationToken.None; var exception = new OperationCanceledException(); Func act = () => MeasureFailureAsync(profiler, actionOverload, exception, token); var assertion = await act.Should().ThrowAsync(); assertion.Which.Should().BeSameAs(exception); sink.Events.Should().ContainSingle(e => e.Level == LogEventLevel.Error && ReferenceEquals(e.Exception, exception)); sink.Events.Should().NotContain(e => e.Level == LogEventLevel.Debug || e.Properties.ContainsKey("Measurements")); } [Theory] [InlineData(false)] [InlineData(true)] public async Task MeasureExecutionAsync_WithCanceledToken_ShouldKeepOtherExceptionsAsErrors(bool actionOverload) { var sink = new CollectingSink(); IProfiler profiler = CreateProfiler(sink); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); var exception = new InvalidOperationException("boom"); Func act = () => MeasureFailureAsync(profiler, actionOverload, exception, cancellation.Token); var assertion = await act.Should().ThrowAsync(); assertion.Which.Should().BeSameAs(exception); sink.Events.Should().ContainSingle(e => e.Level == LogEventLevel.Error && ReferenceEquals(e.Exception, exception)); sink.Events.Should().NotContain(e => e.Level == LogEventLevel.Debug || e.Properties.ContainsKey("Measurements")); } [Theory] [InlineData(false)] [InlineData(true)] public async Task MeasureExecutionAsync_WithoutToken_ShouldKeepCancellationAsError(bool actionOverload) { var sink = new CollectingSink(); IProfiler profiler = CreateProfiler(sink); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); var exception = new OperationCanceledException(cancellation.Token); Func act = () => actionOverload ? profiler.MeasureExecutionAsync(() => Task.FromException(exception), "LegacyBlock") : profiler.MeasureExecutionAsync(() => Task.FromException(exception), "LegacyBlock"); var assertion = await act.Should().ThrowAsync(); assertion.Which.Should().BeSameAs(exception); sink.Events.Should().ContainSingle(e => e.Level == LogEventLevel.Error && ReferenceEquals(e.Exception, exception)); sink.Events.Should().NotContain(e => e.Level == LogEventLevel.Debug || e.Properties.ContainsKey("Measurements")); } [Theory] [InlineData(false)] [InlineData(true)] public async Task MeasureExecutionAsync_WithCanceledToken_ShouldAllowSuccessfulWork(bool actionOverload) { var sink = new CollectingSink(); IProfiler profiler = CreateProfiler(sink); using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); bool invoked = false; if (actionOverload) { await profiler.MeasureExecutionAsync(() => { invoked = true; return Task.CompletedTask; }, "SuccessBlock", cancellation.Token); invoked.Should().BeTrue(); } else { int result = await profiler.MeasureExecutionAsync(() => Task.FromResult(42), "SuccessBlock", cancellation.Token); result.Should().Be(42); } sink.Events.Should().ContainSingle(e => e.Level == LogEventLevel.Information && e.Properties.ContainsKey("Measurements")); sink.Events.Should().NotContain(e => e.Level == LogEventLevel.Error || e.Level == LogEventLevel.Debug); } [Theory] [InlineData(false)] [InlineData(true)] public async Task MeasureExecutionAsync_WithToken_ShouldSupportLegacyInterfaceImplementations(bool actionOverload) { var legacyProfiler = new LegacyProfiler(); IProfiler profiler = legacyProfiler; using var cancellation = new CancellationTokenSource(); cancellation.Cancel(); bool invoked = false; if (actionOverload) { await profiler.MeasureExecutionAsync(() => { invoked = true; return Task.CompletedTask; }, "LegacyBlock", cancellation.Token); invoked.Should().BeTrue(); } else { int result = await profiler.MeasureExecutionAsync(() => Task.FromResult(42), "LegacyBlock", cancellation.Token); result.Should().Be(42); } legacyProfiler.BlockName.Should().Be("LegacyBlock"); } private sealed class LegacyProfiler : IProfiler { public string? BlockName { get; private set; } public Task MeasureExecutionAsync(Func> function, string blockName = "") { BlockName = blockName; return function(); } public Task MeasureExecutionAsync(Func action, string blockName = "") { BlockName = blockName; return action(); } } private static IProfiler CreateProfiler(CollectingSink sink) { return new Profiler(new LoggerConfiguration() .MinimumLevel.Verbose() .WriteTo.Sink(sink) .CreateLogger()); } private static Task MeasureFailureAsync(IProfiler profiler, bool actionOverload, Exception exception, CancellationToken cancellationToken) { return actionOverload ? profiler.MeasureExecutionAsync(() => Task.FromException(exception), "CanceledBlock", cancellationToken) : profiler.MeasureExecutionAsync(() => Task.FromException(exception), "CanceledBlock", cancellationToken); } private sealed class CollectingSink : ILogEventSink { public ConcurrentQueue Events { get; } = new(); public void Emit(LogEvent logEvent) { Events.Enqueue(logEvent); } } }