File: Logging\HttpClientLoggingExtensionsTest.cs
Project: ..\..\..\test\Libraries\Microsoft.Extensions.Http.Diagnostics.Tests\Microsoft.Extensions.Http.Diagnostics.Tests.csproj (Microsoft.Extensions.Http.Diagnostics.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AutoFixture;
using FluentAssertions;
using Microsoft.Extensions.Compliance.Classification;
using Microsoft.Extensions.Compliance.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Hosting.Testing;
using Microsoft.Extensions.Http.Diagnostics;
using Microsoft.Extensions.Http.Logging.Internal;
using Microsoft.Extensions.Http.Logging.Test.Internal;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
 
namespace Microsoft.Extensions.Http.Logging.Test;
 
public class HttpClientLoggingExtensionsTest
{
    private readonly Fixture _fixture;
 
    public HttpClientLoggingExtensionsTest()
    {
        _fixture = new Fixture();
    }
 
    [Fact]
    public void AddHttpClientLogging_AnyArgumentIsNull_Throws()
    {
        var act = () => ((IHttpClientBuilder)null!).AddExtendedHttpClientLogging();
        act.Should().Throw<ArgumentNullException>();
 
        act = () => ((IHttpClientBuilder)null!).AddExtendedHttpClientLogging(_ => { });
        act.Should().Throw<ArgumentNullException>();
 
        act = () => ((IHttpClientBuilder)null!).AddExtendedHttpClientLogging(Mock.Of<IConfigurationSection>());
        act.Should().Throw<ArgumentNullException>();
 
        act = () => Mock.Of<IHttpClientBuilder>().AddExtendedHttpClientLogging((Action<LoggingOptions>)null!);
        act.Should().Throw<ArgumentNullException>();
 
        act = () => Mock.Of<IHttpClientBuilder>().AddExtendedHttpClientLogging((IConfigurationSection)null!);
        act.Should().Throw<ArgumentNullException>();
    }
 
    [Fact]
    public void AddHttpClientLogging_ServiceCollection_AnyArgumentIsNull_Throws()
    {
        var act = () => ((IServiceCollection)null!).AddExtendedHttpClientLogging();
        act.Should().Throw<ArgumentNullException>();
 
        act = () => ((IServiceCollection)null!).AddExtendedHttpClientLogging(_ => { });
        act.Should().Throw<ArgumentNullException>();
 
        act = () => ((IServiceCollection)null!).AddExtendedHttpClientLogging(Mock.Of<IConfigurationSection>());
        act.Should().Throw<ArgumentNullException>();
 
        act = () => Mock.Of<IServiceCollection>().AddExtendedHttpClientLogging((Action<LoggingOptions>)null!);
        act.Should().Throw<ArgumentNullException>();
 
        act = () => Mock.Of<IServiceCollection>().AddExtendedHttpClientLogging((IConfigurationSection)null!);
        act.Should().Throw<ArgumentNullException>();
    }
 
    [Fact]
    public void AddHttpClientLogEnricher_AnyArgumentIsNull_Throws()
    {
        var act = () => ((IServiceCollection)null!).AddHttpClientLogEnricher<EmptyEnricher>();
        act.Should().Throw<ArgumentNullException>();
    }
 
    [Fact]
    public void AddHttpClientLogging_ConfiguredOptionsWithNamedClient_ShouldNotBeSame()
    {
        var services = new ServiceCollection();
 
        using var provider = services
            .AddHttpClient("test1")
            .AddExtendedHttpClientLogging(options => options.BodyReadTimeout = TimeSpan.FromSeconds(1))
            .Services
            .AddHttpClient("test2")
            .AddExtendedHttpClientLogging(options => options.BodyReadTimeout = TimeSpan.FromSeconds(2))
            .Services
            .BuildServiceProvider();
 
        var optionsFirst = provider.GetRequiredService<IOptionsMonitor<LoggingOptions>>().Get("test1");
        var optionsSecond = provider.GetRequiredService<IOptionsMonitor<LoggingOptions>>().Get("test2");
        optionsFirst.Should().NotBeNull();
        optionsSecond.Should().NotBeNull();
        optionsFirst.Should().NotBeEquivalentTo(optionsSecond);
        optionsFirst.BodyReadTimeout.Should().Be(TimeSpan.FromSeconds(1));
        optionsSecond.BodyReadTimeout.Should().Be(TimeSpan.FromSeconds(2));
    }
 
    [Fact]
    public void AddHttpClientLogging_ConfiguredOptionsWithTypedClient_ShouldNotBeSame()
    {
        var services = new ServiceCollection();
 
        using var provider = services
            .AddHttpClient<ITestHttpClient1, TestHttpClient1>()
            .AddExtendedHttpClientLogging(options => options.BodyReadTimeout = TimeSpan.FromSeconds(1))
            .Services
            .AddHttpClient<ITestHttpClient2, TestHttpClient2>()
            .AddExtendedHttpClientLogging(options => options.BodyReadTimeout = TimeSpan.FromSeconds(2))
            .Services
            .BuildServiceProvider();
 
        var optionsFirst = provider.GetRequiredService<IOptionsMonitor<LoggingOptions>>().Get(nameof(ITestHttpClient1));
        var optionsSecond = provider.GetRequiredService<IOptionsMonitor<LoggingOptions>>().Get(nameof(ITestHttpClient2));
        optionsFirst.Should().NotBeNull();
        optionsSecond.Should().NotBeNull();
        optionsFirst.Should().NotBeEquivalentTo(optionsSecond);
        optionsFirst.BodyReadTimeout.Should().Be(TimeSpan.FromSeconds(1));
        optionsSecond.BodyReadTimeout.Should().Be(TimeSpan.FromSeconds(2));
    }
 
    [Fact]
    public void AddHttpClientLogging_DefaultOptions_CreatesOptionsCorrectly()
    {
        var services = new ServiceCollection();
 
        using var provider = services
            .AddHttpClient("")
            .AddExtendedHttpClientLogging(o => o.RequestHeadersDataClasses.Add("test1", FakeTaxonomy.PrivateData))
            .Services
            .AddHttpClient("")
            .AddExtendedHttpClientLogging(o => o.RequestHeadersDataClasses.Add("test2", FakeTaxonomy.PrivateData))
            .Services
            .BuildServiceProvider();
 
        var options = provider.GetRequiredService<IOptions<LoggingOptions>>().Value;
        options.RequestHeadersDataClasses.Should().HaveCount(2);
        options.RequestHeadersDataClasses.Should().ContainKeys(new List<string> { "test1", "test2" });
        options.RequestHeadersDataClasses.Should().ContainValues(new List<DataClassification> { FakeTaxonomy.PrivateData });
    }
 
    [Fact]
    public void AddHttpClientLogging_GivenActionDelegate_RegistersInDi()
    {
        var requestBodyContentType = "application/json";
        var responseBodyContentType = "application/json";
        var requestHeader = _fixture.Create<string>();
        var responseHeader = _fixture.Create<string>();
        var bodyReadTimeout = TimeSpan.FromSeconds(1);
        var bodySizeLimit = 100;
        var formatRequestPath = _fixture.Create<OutgoingPathLoggingMode>();
        var formatRequestPathParameters = _fixture.Create<HttpRouteParameterRedactionMode>();
        var logStart = _fixture.Create<bool>();
        var paramToRedact = new KeyValuePair<string, DataClassification>("userId", FakeTaxonomy.PrivateData);
 
        var services = new ServiceCollection();
 
        services
            .AddHttpClient("test")
            .AddExtendedHttpClientLogging(options =>
            {
                options.RequestBodyContentTypes.Add(requestBodyContentType);
                options.ResponseBodyContentTypes.Add(responseBodyContentType);
                options.BodyReadTimeout = bodyReadTimeout;
                options.BodySizeLimit = bodySizeLimit;
                options.RequestPathLoggingMode = formatRequestPath;
                options.RequestPathParameterRedactionMode = formatRequestPathParameters;
                options.RequestHeadersDataClasses.Add(requestHeader, FakeTaxonomy.PrivateData);
                options.ResponseHeadersDataClasses.Add(responseHeader, FakeTaxonomy.PrivateData);
                options.RouteParameterDataClasses.Add(paramToRedact);
                options.LogRequestStart = logStart;
            });
 
        using var provider = services.BuildServiceProvider();
        var options = provider.GetRequiredService<IOptionsMonitor<LoggingOptions>>().Get("test");
 
        options.Should().NotBeNull();
        options.RequestBodyContentTypes.Should().ContainSingle();
        options.RequestBodyContentTypes.Should().Contain(requestBodyContentType);
        options.ResponseBodyContentTypes.Should().ContainSingle();
        options.ResponseBodyContentTypes.Should().Contain(responseBodyContentType);
        options.BodyReadTimeout.Should().Be(bodyReadTimeout);
        options.BodySizeLimit.Should().Be(bodySizeLimit);
        options.RequestPathLoggingMode.Should().Be(formatRequestPath);
        options.RequestPathParameterRedactionMode.Should().Be(formatRequestPathParameters);
        options.RequestHeadersDataClasses.Should().ContainSingle();
        options.RequestHeadersDataClasses.Should().Contain(requestHeader, FakeTaxonomy.PrivateData);
        options.ResponseHeadersDataClasses.Should().ContainSingle();
        options.ResponseHeadersDataClasses.Should().Contain(responseHeader, FakeTaxonomy.PrivateData);
        options.RouteParameterDataClasses.Should().ContainSingle();
        options.RouteParameterDataClasses.Should().Contain(paramToRedact);
        options.LogRequestStart.Should().Be(logStart);
    }
 
    [Fact]
    public async Task AddHttpClientLogging_GivenInvalidOptions_Throws()
    {
        using var host = FakeHost.CreateBuilder()
            .ConfigureServices(services =>
            {
                services
                    .AddFakeRedaction()
                    .AddHttpClient("test")
                    .AddExtendedHttpClientLogging(options =>
                    {
                        options.BodyReadTimeout = TimeSpan.Zero;
                        options.BodySizeLimit = -1;
                    });
            })
            .Build();
 
        var act = async () => await host.StartAsync().ConfigureAwait(false);
        await act.Should().ThrowAsync<OptionsValidationException>();
    }
 
    [Theory]
    [InlineData(2)]
    [InlineData(5)]
    [InlineData(30)]
    [InlineData(59)]
    [InlineData(17)]
    public void AddHttpClientLogging_GivenConfigurationSection_SetsTimeoutCorrectly(int seconds)
    {
        var timeoutValue = TimeSpan.FromSeconds(seconds);
 
        using var provider = new ServiceCollection()
            .AddHttpClient("test")
            .AddExtendedHttpClientLogging(TestConfiguration.GetHttpClientLoggingConfigurationSection(timeoutValue))
            .Services
            .BuildServiceProvider();
        var options = provider
            .GetRequiredService<IOptionsMonitor<LoggingOptions>>().Get("test");
 
        options.Should().NotBeNull();
        options.BodyReadTimeout.Should().Be(timeoutValue);
    }
 
    [Fact]
    public void AddHttpClientLogging_GivenConfigurationSection_BindsDataClasses()
    {
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.LogRequestStart)}"] = "true",
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.RequestQueryParametersDataClasses)}:search:{nameof(DataClassification.TaxonomyName)}"] = "MyTaxonomy",
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.RequestQueryParametersDataClasses)}:search:{nameof(DataClassification.Value)}"] = "PrivateData",
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.LogBody)}"] = "true",
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.BodySizeLimit)}"] = "1024",
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.BodyReadTimeout)}"] = "00:00:02",
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.RequestBodyContentTypes)}:0"] = "application/json",
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.RequestBodyContentTypes)}:1"] = null,
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.ResponseBodyContentTypes)}:0"] = "text/plain",
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.RequestHeadersDataClasses)}:User-Agent"] = "None",
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.ResponseHeadersDataClasses)}:Content-Type"] = "Unknown",
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.RequestPathLoggingMode)}"] = "Structured",
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.RequestPathParameterRedactionMode)}"] = "None",
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.RouteParameterDataClasses)}:userId"] = "MyTaxonomy:EUII",
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.LogContentHeaders)}"] = "true",
            })
            .Build();
        var configurationSection = configuration.GetSection(nameof(LoggingOptions));
 
        using var provider = new ServiceCollection()
            .AddHttpClient("test")
            .AddExtendedHttpClientLogging(configurationSection)
            .Services
            .BuildServiceProvider();
 
        var options = provider.GetRequiredService<IOptionsMonitor<LoggingOptions>>().Get("test");
 
        configurationSection.GetChildren().Select(child => child.Key).Should().BeEquivalentTo(
            typeof(LoggingOptions).GetProperties()
                .Where(property => property.SetMethod?.IsPublic is true)
                .Select(property => property.Name));
        options.LogRequestStart.Should().BeTrue();
        options.RequestQueryParametersDataClasses.Should().Contain("search", new DataClassification("MyTaxonomy", "PrivateData"));
        options.LogBody.Should().BeTrue();
        options.BodySizeLimit.Should().Be(1024);
        options.BodyReadTimeout.Should().Be(TimeSpan.FromSeconds(2));
        options.RequestBodyContentTypes.Should().Equal("application/json");
        options.ResponseBodyContentTypes.Should().Equal("text/plain");
        options.RequestHeadersDataClasses.Should().Contain("User-Agent", DataClassification.None);
        options.ResponseHeadersDataClasses.Should().Contain("Content-Type", DataClassification.Unknown);
        options.RequestPathLoggingMode.Should().Be(OutgoingPathLoggingMode.Structured);
        options.RequestPathParameterRedactionMode.Should().Be(HttpRouteParameterRedactionMode.None);
        options.RouteParameterDataClasses.Should().Contain("userId", new DataClassification("MyTaxonomy", "EUII"));
        options.LogContentHeaders.Should().BeTrue();
    }
 
    [Fact]
    public void LoggingOptionsConfigureOptions_HandlesOptionNamesAndMissingSections()
    {
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.BodySizeLimit)}"] = "1024",
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.LogBody)}"] = "false",
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.LogContentHeaders)}"] = "false",
            })
            .Build();
        var section = configuration.GetSection(nameof(LoggingOptions));
        int defaultBodySizeLimit = new LoggingOptions().BodySizeLimit;
 
        var defaultOptions = new LoggingOptions
        {
            LogBody = true,
            LogContentHeaders = true,
        };
        var defaultConfigurator = new LoggingOptionsConfigureOptions(Microsoft.Extensions.Options.Options.DefaultName, section);
        ((IConfigureOptions<LoggingOptions>)defaultConfigurator).Configure(defaultOptions);
 
        defaultOptions.BodySizeLimit.Should().Be(1024);
        defaultOptions.LogBody.Should().BeFalse();
        defaultOptions.LogContentHeaders.Should().BeFalse();
 
        var mismatchedOptions = new LoggingOptions();
        new LoggingOptionsConfigureOptions("test", section).Configure("other", mismatchedOptions);
 
        mismatchedOptions.BodySizeLimit.Should().Be(defaultBodySizeLimit);
 
        var missingSectionOptions = new LoggingOptions();
        new LoggingOptionsConfigureOptions("test", configuration.GetSection("Missing")).Configure("test", missingSectionOptions);
 
        missingSectionOptions.BodySizeLimit.Should().Be(defaultBodySizeLimit);
    }
 
    [Fact]
    public void AddHttpClientLogging_GivenDocumentedConfigurationSample_CreatesClient()
    {
        using var stream = new MemoryStream(Encoding.UTF8.GetBytes("""
        {
          "HttpClientLogging": {
            "LogRequestStart": false,
            "LogBody": false,
            "BodySizeLimit": 32768,
            "BodyReadTimeout": "00:00:01",
            "RequestHeadersDataClasses": {
              "User-Agent": "None",
              "Content-Type": "None"
            },
            "ResponseHeadersDataClasses": {
              "Content-Type": "None"
            },
            "RequestPathLoggingMode": "Formatted",
            "RequestPathParameterRedactionMode": "Strict"
          }
        }
        """));
 
        var configuration = new ConfigurationBuilder()
            .AddJsonStream(stream)
            .Build();
 
        var services = new ServiceCollection();
        services.AddLogging();
        services.AddRedaction();
        services.AddHttpClient("test")
            .AddExtendedHttpClientLogging(configuration.GetSection("HttpClientLogging"));
 
        using var provider = services.BuildServiceProvider();
 
        using var client = provider.GetRequiredService<IHttpClientFactory>().CreateClient("test");
        client.Should().NotBeNull();
 
        var options = provider.GetRequiredService<IOptionsMonitor<LoggingOptions>>().Get("test");
        options.LogRequestStart.Should().BeFalse();
        options.LogBody.Should().BeFalse();
        options.BodySizeLimit.Should().Be(32768);
        options.BodyReadTimeout.Should().Be(TimeSpan.FromSeconds(1));
        options.RequestHeadersDataClasses.Should().Contain("User-Agent", DataClassification.None);
        options.RequestHeadersDataClasses.Should().Contain("Content-Type", DataClassification.None);
        options.ResponseHeadersDataClasses.Should().Contain("Content-Type", DataClassification.None);
        options.RequestPathLoggingMode.Should().Be(OutgoingPathLoggingMode.Formatted);
        options.RequestPathParameterRedactionMode.Should().Be(HttpRouteParameterRedactionMode.Strict);
    }
 
    [Fact]
    public void AddHttpClientLogging_GivenInvalidDataClassification_Throws()
    {
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.RequestHeadersDataClasses)}:User-Agent"] = "invalid",
            })
            .Build();
 
        using var provider = new ServiceCollection()
            .AddHttpClient("test")
            .AddExtendedHttpClientLogging(configuration.GetSection(nameof(LoggingOptions)))
            .Services
            .BuildServiceProvider();
 
        var act = () => provider.GetRequiredService<IOptionsMonitor<LoggingOptions>>().Get("test");
 
        act.Should().Throw<InvalidOperationException>()
            .WithMessage("*LoggingOptions:RequestHeadersDataClasses:User-Agent*");
    }
 
    [Fact]
    public void AddHttpClientLogging_GivenInvalidScalarValue_Throws()
    {
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.RequestPathLoggingMode)}"] = "invalid",
            })
            .Build();
 
        using var provider = new ServiceCollection()
            .AddHttpClient("test")
            .AddExtendedHttpClientLogging(configuration.GetSection(nameof(LoggingOptions)))
            .Services
            .BuildServiceProvider();
 
        var act = () => provider.GetRequiredService<IOptionsMonitor<LoggingOptions>>().Get("test");
 
        act.Should().Throw<InvalidOperationException>()
            .WithMessage("*LoggingOptions:RequestPathLoggingMode*");
    }
 
    [Fact]
    public void AddHttpClientLogging_GivenConfigurationReload_UpdatesOptions()
    {
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.BodySizeLimit)}"] = "1024",
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.RequestHeadersDataClasses)}:User-Agent"] = "None",
            })
            .Build();
 
        using var provider = new ServiceCollection()
            .AddHttpClient("test")
            .AddExtendedHttpClientLogging(configuration.GetSection(nameof(LoggingOptions)))
            .Services
            .BuildServiceProvider();
        var options = provider.GetRequiredService<IOptionsMonitor<LoggingOptions>>();
 
        options.Get("test").BodySizeLimit.Should().Be(1024);
        options.Get("test").RequestHeadersDataClasses.Should().Contain("User-Agent", DataClassification.None);
 
        configuration[$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.BodySizeLimit)}"] = "2048";
        configuration[$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.RequestHeadersDataClasses)}:User-Agent"] = "Unknown";
        configuration.Reload();
 
        options.Get("test").BodySizeLimit.Should().Be(2048);
        options.Get("test").RequestHeadersDataClasses.Should().Contain("User-Agent", DataClassification.Unknown);
    }
 
    [Fact]
    public void AddHttpClientLogEnricher_RegistersEnricherInDI()
    {
        using var provider = new ServiceCollection()
            .AddHttpClientLogEnricher<EmptyEnricher>()
            .BuildServiceProvider();
 
        var enricherRegistered = provider.GetService<IHttpClientLogEnricher>();
 
        enricherRegistered.Should().NotBeNull();
        enricherRegistered.Should().BeOfType<EmptyEnricher>();
    }
 
    [Fact]
    public void AddHttpClientLogging_ServiceCollection_GivenActionDelegate_RegistersInDi()
    {
        var requestBodyContentType = "application/json";
        var responseBodyContentType = "application/json";
        var requestHeader = _fixture.Create<string>();
        var responseHeader = _fixture.Create<string>();
        var bodyReadTimeout = TimeSpan.FromSeconds(1);
        var bodySizeLimit = 100;
        var formatRequestPath = _fixture.Create<OutgoingPathLoggingMode>();
        var formatRequestPathParameters = _fixture.Create<HttpRouteParameterRedactionMode>();
        var logStart = _fixture.Create<bool>();
        var paramToRedact = new KeyValuePair<string, DataClassification>("userId", FakeTaxonomy.PrivateData);
 
        var services = new ServiceCollection();
 
        services
            .AddFakeRedaction()
            .AddHttpClient()
            .AddExtendedHttpClientLogging(options =>
            {
                options.RequestBodyContentTypes.Add(requestBodyContentType);
                options.ResponseBodyContentTypes.Add(responseBodyContentType);
                options.BodyReadTimeout = bodyReadTimeout;
                options.BodySizeLimit = bodySizeLimit;
                options.RequestPathLoggingMode = formatRequestPath;
                options.RequestPathParameterRedactionMode = formatRequestPathParameters;
                options.RequestHeadersDataClasses.Add(requestHeader, FakeTaxonomy.PrivateData);
                options.ResponseHeadersDataClasses.Add(responseHeader, FakeTaxonomy.PrivateData);
                options.RouteParameterDataClasses.Add(paramToRedact);
                options.LogRequestStart = logStart;
            });
 
        using var provider = services.BuildServiceProvider();
        var options = provider.GetRequiredService<IOptions<LoggingOptions>>().Value;
 
        options.Should().NotBeNull();
        options.RequestBodyContentTypes.Should().ContainSingle();
        options.RequestBodyContentTypes.Should().Contain(requestBodyContentType);
        options.ResponseBodyContentTypes.Should().ContainSingle();
        options.ResponseBodyContentTypes.Should().Contain(responseBodyContentType);
        options.BodyReadTimeout.Should().Be(bodyReadTimeout);
        options.BodySizeLimit.Should().Be(bodySizeLimit);
        options.RequestPathLoggingMode.Should().Be(formatRequestPath);
        options.RequestPathParameterRedactionMode.Should().Be(formatRequestPathParameters);
        options.RequestHeadersDataClasses.Should().ContainSingle();
        options.RequestHeadersDataClasses.Should().Contain(requestHeader, FakeTaxonomy.PrivateData);
        options.ResponseHeadersDataClasses.Should().ContainSingle();
        options.ResponseHeadersDataClasses.Should().Contain(responseHeader, FakeTaxonomy.PrivateData);
        options.RouteParameterDataClasses.Should().ContainSingle();
        options.RouteParameterDataClasses.Should().Contain(paramToRedact);
        options.LogRequestStart.Should().Be(logStart);
 
        using var httpClient = provider.GetRequiredService<IHttpClientFactory>().CreateClient();
        Assert.NotNull(httpClient);
    }
 
    [Fact]
    public async Task AddHttpClientLogging_ServiceCollection_GivenInvalidOptions_Throws()
    {
        using var provider = new ServiceCollection()
            .AddFakeRedaction()
            .AddHttpClient()
            .AddExtendedHttpClientLogging(options =>
            {
                options.BodyReadTimeout = TimeSpan.Zero;
                options.BodySizeLimit = -1;
            })
            .BuildServiceProvider();
 
        var act = () =>
            provider
                .GetRequiredService<IHostedService>()
                .StartAsync(CancellationToken.None);
        await act.Should().ThrowAsync<OptionsValidationException>();
    }
 
    [Fact]
    public void AddHttpClientLogging_ServiceCollectionAndHttpClientBuilder_DoesNotDuplicate()
    {
        const string ClientName = "test";
 
        using var provider = new ServiceCollection()
            .AddFakeRedaction()
            .AddHttpClient(ClientName)
            .AddExtendedHttpClientLogging(x =>
            {
                x.BodySizeLimit = 100500;
                x.RequestHeadersDataClasses.Add(ClientName, FakeTaxonomy.PublicData);
            }).Services
            .AddExtendedHttpClientLogging(x =>
            {
                x.BodySizeLimit = 347;
                x.RequestHeadersDataClasses.Add("default", FakeTaxonomy.PrivateData);
            })
            .BuildServiceProvider();
 
        EnsureSingleLogger<HttpClientLogger>(provider, ClientName);
    }
 
    [Fact]
    public void AddHttpClientLogging_HttpClientBuilderAndServiceCollection_DoesNotDuplicate()
    {
        const string ClientName = "test";
 
        using var provider = new ServiceCollection()
            .AddFakeRedaction()
            .AddExtendedHttpClientLogging()
            .AddHttpClient(ClientName)
            .AddExtendedHttpClientLogging()
            .Services.BuildServiceProvider();
 
        EnsureSingleLogger<HttpClientLogger>(provider, ClientName);
    }
 
    [Theory]
    [InlineData(2)]
    [InlineData(5)]
    [InlineData(30)]
    [InlineData(59)]
    [InlineData(17)]
    public void AddHttpClientLogging_ServiceCollection_GivenConfigurationSection_SetsTimeoutCorrectly(int seconds)
    {
        var timeoutValue = TimeSpan.FromSeconds(seconds);
 
        using var provider = new ServiceCollection()
            .AddFakeRedaction()
            .AddHttpClient()
            .AddExtendedHttpClientLogging(TestConfiguration.GetHttpClientLoggingConfigurationSection(timeoutValue))
            .BuildServiceProvider();
        var options = provider
            .GetRequiredService<IOptions<LoggingOptions>>().Value;
 
        options.Should().NotBeNull();
        options.BodyReadTimeout.Should().Be(timeoutValue);
 
        using var httpClient = provider.GetRequiredService<IHttpClientFactory>().CreateClient();
        Assert.NotNull(httpClient);
    }
 
    [Fact]
    public void AddHttpClientLogging_ServiceCollection_GivenConfigurationSection_BindsDataClasses()
    {
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.RequestQueryParametersDataClasses)}:search"] = "MyTaxonomy:PrivateData",
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.RequestHeadersDataClasses)}:User-Agent"] = "None",
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.ResponseHeadersDataClasses)}:Content-Type"] = "Unknown",
                [$"{nameof(LoggingOptions)}:{nameof(LoggingOptions.RouteParameterDataClasses)}:userId"] = "MyTaxonomy:EUII",
            })
            .Build();
 
        using var provider = new ServiceCollection()
            .AddHttpClient()
            .AddExtendedHttpClientLogging(configuration.GetSection(nameof(LoggingOptions)))
            .BuildServiceProvider();
 
        var options = provider.GetRequiredService<IOptions<LoggingOptions>>().Value;
 
        options.RequestQueryParametersDataClasses.Should().Contain("search", new DataClassification("MyTaxonomy", "PrivateData"));
        options.RequestHeadersDataClasses.Should().Contain("User-Agent", DataClassification.None);
        options.ResponseHeadersDataClasses.Should().Contain("Content-Type", DataClassification.Unknown);
        options.RouteParameterDataClasses.Should().Contain("userId", new DataClassification("MyTaxonomy", "EUII"));
    }
 
    [Fact]
    public void AddHttpClientLogging_ServiceCollection_CreatesClientSuccessfully()
    {
        using var sp = new ServiceCollection()
            .AddFakeRedaction()
            .AddHttpClient()
            .AddExtendedHttpClientLogging()
            .BuildServiceProvider();
 
        using var httpClient = sp.GetRequiredService<IHttpClientFactory>().CreateClient();
        Assert.NotNull(httpClient);
    }
 
    [Fact]
    public async Task LatencyInfo_IsPopulated_WhenLoggerWrapsHandlersPipeline()
    {
        await using var sp = new ServiceCollection()
            .AddLatencyContext()
            .AddRedaction()
            .AddHttpClientLatencyTelemetry()
            .AddHttpClient("test")
            .ConfigurePrimaryHttpMessageHandler(() => new TestMessageHandler(_ =>
            {
                var response = new HttpResponseMessage(HttpStatusCode.OK);
                response.Headers.TryAddWithoutValidation(TelemetryConstants.ServerApplicationNameHeader, "TestServer");
                return response;
            }))
            .AddExtendedHttpClientLogging(wrapHandlersPipeline: true)
            .Services
            .AddFakeLogging()
            .BuildServiceProvider();
 
        var client = sp.GetRequiredService<IHttpClientFactory>().CreateClient("test");
 
        using var response = await client.GetAsync("http://localhost/api");
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
 
        var collector = sp.GetFakeLogCollector();
        var record = collector.LatestRecord;
        Assert.NotNull(record);
 
        var latencyInfo = record.GetStructuredStateValue("LatencyInfo");
        Assert.False(string.IsNullOrEmpty(latencyInfo));
        Assert.StartsWith("v1.0,", latencyInfo);
        Assert.Contains("TestServer", latencyInfo);
    }
 
    [Fact]
    public async Task AddExtendedHttpClientLogging_WrapHandlersPipelineParameter_LogsCorrectNumberOfAttempts()
    {
        int attemptCount = 0;
        var serviceCollection = new ServiceCollection();
 
        serviceCollection.AddTransient(_ =>
            new TestMessageHandler(_ =>
            {
                attemptCount++;
                return attemptCount % 3 == 0 // every 3rd attempt succeeds
                    ? new HttpResponseMessage(HttpStatusCode.OK)
                    : throw new HttpRequestException("Simulated failure");
            }));
 
        serviceCollection.AddTransient<TestRetryHandler>();
 
        serviceCollection
            .AddFakeRedaction()
            .AddFakeLogging()
            .AddHttpClient("outer")
                .ConfigurePrimaryHttpMessageHandler<TestMessageHandler>()
                .AddHttpMessageHandler<TestRetryHandler>()
                .AddExtendedHttpClientLogging(o => o.LogRequestStart = true, wrapHandlersPipeline: true);
 
        serviceCollection
            .AddHttpClient("inner")
                .ConfigurePrimaryHttpMessageHandler<TestMessageHandler>()
                .AddHttpMessageHandler<TestRetryHandler>()
                .AddExtendedHttpClientLogging(o => o.LogRequestStart = true, wrapHandlersPipeline: false);
 
        using var services = serviceCollection.BuildServiceProvider();
        var factory = services.GetRequiredService<IHttpClientFactory>();
        var collector = services.GetFakeLogCollector();
 
        var outerClient = factory.CreateClient("outer");
        attemptCount = 0;
        collector.Clear();
 
        _ = await outerClient.GetAsync("http://localhost/api");
 
        var outerLogs = collector.GetSnapshot();
        Assert.Equal(2, outerLogs.Count); // 1 request start + 1 request stop
 
        var innerClient = factory.CreateClient("inner");
        attemptCount = 0;
        collector.Clear();
 
        _ = await innerClient.GetAsync("http://localhost/api");
 
        var innerLogs = collector.GetSnapshot();
        Assert.Equal(6, innerLogs.Count); // 3 attempts × (1 start + 1 stop/failed)
    }
 
    private sealed class TestMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
        : HttpMessageHandler
    {
        protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            return Task.FromResult(responseFactory(request));
        }
    }
 
    private sealed class TestRetryHandler : DelegatingHandler
    {
        private const int MaxRetries = 2;
 
        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            HttpRequestException? lastException = null;
 
            for (int attempt = 0; attempt <= MaxRetries; attempt++)
            {
                try
                {
                    return await base.SendAsync(request, cancellationToken);
                }
                catch (HttpRequestException ex)
                {
                    lastException = ex;
                    if (attempt == MaxRetries)
                    {
                        throw;
                    }
                }
            }
 
            throw lastException!;
        }
    }
 
    private static void EnsureSingleLogger<T>(IServiceProvider serviceProvider, string serviceKey)
        where T : IHttpClientLogger
    {
        var loggers = serviceProvider.GetServices<T>();
        loggers.Should().ContainSingle();
 
        var keyedLoggers = serviceProvider.GetKeyedServices<T>(serviceKey);
        keyedLoggers.Should().ContainSingle();
    }
}