File: AspireRedisExtensionsTests.cs
Web Access
Project: src\tests\Aspire.StackExchange.Redis.Tests\Aspire.StackExchange.Redis.Tests.csproj (Aspire.StackExchange.Redis.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.Runtime.CompilerServices;
using Aspire.Components.Common.Tests;
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.DotNet.RemoteExecutor;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.StackExchangeRedis;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using OpenTelemetry.Instrumentation.StackExchangeRedis;
using OpenTelemetry.Trace;
using StackExchange.Redis;
using StackExchange.Redis.Profiling;
using Xunit;
 
namespace Aspire.StackExchange.Redis.Tests;
 
public class AspireRedisExtensionsTests : IClassFixture<RedisContainerFixture>
{
    private const string TestingEndpoint = "localhost";
    private readonly RedisContainerFixture _containerFixture;
    private string ConnectionString => _containerFixture.GetConnectionString();
 
    public AspireRedisExtensionsTests(RedisContainerFixture containerFixture)
    {
        _containerFixture = containerFixture;
    }
 
    [Fact]
    [RequiresDocker]
    public void AllowsConfigureConfigurationOptions()
    {
        var builder = Host.CreateEmptyApplicationBuilder(null);
        PopulateConfiguration(builder.Configuration);
 
        builder.AddRedisClient("redis");
 
        builder.Services.Configure<ConfigurationOptions>(options =>
        {
            options.User = "aspire-test-user";
        });
 
        using var host = builder.Build();
        var connection = host.Services.GetRequiredService<IConnectionMultiplexer>();
 
        Assert.Contains("aspire-test-user", connection.Configuration);
    }
 
    [Theory]
    [RequiresDocker]
    [InlineData(true)]
    [InlineData(false)]
    public void ReadsFromConnectionStringsCorrectly(bool useKeyed)
    {
        var builder = Host.CreateEmptyApplicationBuilder(null);
        builder.Configuration.AddInMemoryCollection([
            new KeyValuePair<string, string?>("ConnectionStrings:myredis", ConnectionString)
        ]);
 
        if (useKeyed)
        {
            builder.AddKeyedRedisClient("myredis");
        }
        else
        {
            builder.AddRedisClient("myredis");
        }
 
        using var host = builder.Build();
        var connection = useKeyed ?
            host.Services.GetRequiredKeyedService<IConnectionMultiplexer>("myredis") :
            host.Services.GetRequiredService<IConnectionMultiplexer>();
 
        Assert.Contains(ConnectionString, connection.Configuration);
    }
 
    [Theory]
    [RequiresDocker]
    [InlineData(true)]
    [InlineData(false)]
    public void ConnectionStringCanBeSetInCode(bool useKeyed)
    {
        var builder = Host.CreateEmptyApplicationBuilder(null);
        builder.Configuration.AddInMemoryCollection([
            new KeyValuePair<string, string?>("ConnectionStrings:redis", "unused")
        ]);
 
        void SetConnectionString(StackExchangeRedisSettings settings) => settings.ConnectionString = ConnectionString;
        if (useKeyed)
        {
            builder.AddKeyedRedisClient("redis", SetConnectionString);
        }
        else
        {
            builder.AddRedisClient("redis", SetConnectionString);
        }
 
        using var host = builder.Build();
        var connection = useKeyed ?
            host.Services.GetRequiredKeyedService<IConnectionMultiplexer>("redis") :
            host.Services.GetRequiredService<IConnectionMultiplexer>();
 
        Assert.Contains(ConnectionString, connection.Configuration);
        // the connection string from config should not be used since code set it explicitly
        Assert.DoesNotContain("unused", connection.Configuration);
    }
 
    [Theory]
    [RequiresDocker]
    [InlineData(true)]
    [InlineData(false)]
    public void ConnectionNameWinsOverConfigSection(bool useKeyed)
    {
        var builder = Host.CreateEmptyApplicationBuilder(null);
 
        var key = useKeyed ? "redis" : null;
        builder.Configuration.AddInMemoryCollection([
            new KeyValuePair<string, string?>(ConformanceTests.CreateConfigKey("Aspire:StackExchange:Redis", key, "ConnectionString"), "unused"),
            new KeyValuePair<string, string?>("ConnectionStrings:redis", ConnectionString)
        ]);
 
        if (useKeyed)
        {
            builder.AddKeyedRedisClient("redis");
        }
        else
        {
            builder.AddRedisClient("redis");
        }
 
        using var host = builder.Build();
        var connection = useKeyed ?
            host.Services.GetRequiredKeyedService<IConnectionMultiplexer>("redis") :
            host.Services.GetRequiredService<IConnectionMultiplexer>();
 
        Assert.Contains(ConnectionString, connection.Configuration);
        // the connection string from config should not be used since it was found in ConnectionStrings
        Assert.DoesNotContain("unused", connection.Configuration);
    }
 
    public static IEnumerable<object[]> AbortOnConnectFailData =>
    [
        [true, GetDefaultConfiguration(), false],
        [false, GetDefaultConfiguration(), false],
 
        [true, GetSetsTrueConfig(true), true],
        [false, GetSetsTrueConfig(false), true],
 
        [true, GetConnectionString(abortConnect: true), true],
        [false, GetConnectionString(abortConnect: true), true],
        [true, GetConnectionString(abortConnect: false), false],
        [false, GetConnectionString(abortConnect: false), false],
    ];
 
    private static IEnumerable<KeyValuePair<string, string?>> GetDefaultConfiguration() =>
    [
        new KeyValuePair<string, string?>("ConnectionStrings:redis", TestingEndpoint)
    ];
 
    private static IEnumerable<KeyValuePair<string, string?>> GetSetsTrueConfig(bool useKeyed) =>
    [
        new KeyValuePair<string, string?>("ConnectionStrings:redis", TestingEndpoint),
        new KeyValuePair<string, string?>(ConformanceTests.CreateConfigKey("Aspire:StackExchange:Redis", useKeyed ? "redis" : null, "ConfigurationOptions:AbortOnConnectFail"), "true")
    ];
 
    private static IEnumerable<KeyValuePair<string, string?>> GetConnectionString(bool abortConnect) =>
    [
        new KeyValuePair<string, string?>("ConnectionStrings:redis", $"{TestingEndpoint},abortConnect={(abortConnect ? "true" : "false")}")
    ];
 
    [Theory]
    [MemberData(nameof(AbortOnConnectFailData))]
    public void AbortOnConnectFailDefaults(bool useKeyed, IEnumerable<KeyValuePair<string, string?>> configValues, bool expectedAbortOnConnect)
    {
        var builder = Host.CreateEmptyApplicationBuilder(null);
        builder.Configuration.AddInMemoryCollection(configValues);
 
        if (useKeyed)
        {
            builder.AddKeyedRedisClient("redis");
        }
        else
        {
            builder.AddRedisClient("redis");
        }
 
        using var host = builder.Build();
        var options = useKeyed ?
            host.Services.GetRequiredService<IOptionsMonitor<ConfigurationOptions>>().Get("redis") :
            host.Services.GetRequiredService<IOptions<ConfigurationOptions>>().Value;
 
        Assert.Equal(expectedAbortOnConnect, options.AbortOnConnectFail);
    }
 
    /// <summary>
    /// Verifies that both distributed and output caching components can be added to the same builder and their HealthChecks don't conflict.
    /// See https://github.com/dotnet/aspire/issues/705
    /// </summary>
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public void MultipleRedisComponentsCanBeAdded(bool useKeyed)
    {
        var builder = Host.CreateEmptyApplicationBuilder(null);
 
        if (useKeyed)
        {
            builder.AddKeyedRedisDistributedCache("redis");
            builder.AddKeyedRedisOutputCache("redis");
        }
        else
        {
            builder.AddRedisDistributedCache("redis");
            builder.AddRedisOutputCache("redis");
        }
 
        using var host = builder.Build();
 
        // Note that IDistributedCache and OutputCacheStore don't support keyed services - so only the Redis ConnectionMultiplexer is keyed.
 
        var distributedCache = host.Services.GetRequiredService<IDistributedCache>();
        Assert.IsAssignableFrom<RedisCache>(distributedCache);
 
        var cacheStore = host.Services.GetRequiredService<IOutputCacheStore>();
        Assert.StartsWith("Redis", cacheStore.GetType().Name);
 
        // Explicitly ensure the HealthCheckService can be retrieved. It validates the registrations in its constructor.
        // See https://github.com/dotnet/aspnetcore/blob/94ad7031db6744409de24f75777a59620cb94d9a/src/HealthChecks/HealthChecks/src/DefaultHealthCheckService.cs#L33-L36
        var healthCheckService = host.Services.GetRequiredService<HealthCheckService>();
        Assert.NotNull(healthCheckService);
    }
 
    [UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_profilingSessionProvider")]
    static extern ref Func<ProfilingSession>? GetProfiler(ConnectionMultiplexer? @this);
 
    [Fact]
    public void KeyedServiceRedisInstrumentation()
    {
        var builder = Host.CreateEmptyApplicationBuilder(null);
 
        builder.AddKeyedRedisClient("redis", settings =>
        {
            settings.ConnectionString = "localhost";
            settings.DisableTracing = ! true;
        });
        using var host = builder.Build();
 
        //This will add the instrumentations.
        var tracerProvider = host.Services.GetRequiredService<TracerProvider>();
 
        var connectionMultiplexer = host.Services.GetRequiredKeyedService<IConnectionMultiplexer>("redis");
        var profiler = GetProfiler(connectionMultiplexer as ConnectionMultiplexer);
 
        Assert.NotNull(profiler);
    }
 
    [Fact]
    [RequiresDocker]
    public void KeyedServiceRedisInstrumentationEndToEnd()
    {
        RemoteExecutor.Invoke(async (connectionString) =>
        {
            var builder = Host.CreateEmptyApplicationBuilder(null);
            builder.Configuration.AddInMemoryCollection([
                new KeyValuePair<string, string?>("ConnectionStrings:redis", connectionString)
            ]);
 
            using var notifier = new ActivityNotifier();
            builder.Services.AddOpenTelemetry().WithTracing(builder => builder.AddProcessor(notifier));
            // set the FlushInterval to to zero so the Activity gets created immediately
            builder.Services.Configure<StackExchangeRedisInstrumentationOptions>(options => options.FlushInterval = TimeSpan.Zero);
 
            builder.AddKeyedRedisClient("redis");
            using var host = builder.Build();
 
            // We start the host to make it build TracerProvider.
            // If we don't, nothing gets reported!
            host.Start();
 
            var connectionMultiplexer = host.Services.GetRequiredKeyedService<IConnectionMultiplexer>("redis");
            var database = connectionMultiplexer.GetDatabase();
            database.StringGet("key");
 
            // read the first activity
            var activityList = await notifier.TakeAsync(1, TimeSpan.FromSeconds(10));
            Assert.Single(activityList);
 
            var activity = activityList[0];
            Assert.Equal("GET", activity.OperationName);
            Assert.Contains(activity.Tags, kvp => kvp.Key == "db.system" && kvp.Value == "redis");
        }, ConnectionString).Dispose();
    }
 
    [Fact]
    [RequiresDocker]
    public async Task CanAddMultipleKeyedServices()
    {
        await using var container2 = await RedisContainerFixture.CreateContainerAsync();
        await using var container3 = await RedisContainerFixture.CreateContainerAsync();
 
        var builder = Host.CreateEmptyApplicationBuilder(null);
        builder.Configuration.AddInMemoryCollection([
            new KeyValuePair<string, string?>("ConnectionStrings:redis1", ConnectionString),
            new KeyValuePair<string, string?>("ConnectionStrings:redis2", container2.GetConnectionString()),
            new KeyValuePair<string, string?>("ConnectionStrings:redis3", container3.GetConnectionString())
        ]);
 
        builder.AddRedisClient("redis1");
        builder.AddKeyedRedisClient("redis2");
        builder.AddKeyedRedisClient("redis3");
 
        using var host = builder.Build();
 
        var connection1 = host.Services.GetRequiredService<IConnectionMultiplexer>();
        var connection2 = host.Services.GetRequiredKeyedService<IConnectionMultiplexer>("redis2");
        var connection3 = host.Services.GetRequiredKeyedService<IConnectionMultiplexer>("redis3");
 
        Assert.NotSame(connection1, connection2);
        Assert.NotSame(connection1, connection3);
        Assert.NotSame(connection2, connection3);
 
        Assert.Equal(ConnectionString, connection1.Configuration);
        Assert.Equal(container2.GetConnectionString(), connection2.Configuration);
        Assert.Equal(container3.GetConnectionString(), connection3.Configuration);
    }
 
    /// <summary>
    /// Tests that you can use a keyed service for a distributed cache, another for an output cache, while also adding a plain Redis service.
    /// </summary>
    [Fact]
    [RequiresDocker]
    public async Task CanAddMultipleKeyedCachingServices()
    {
        await using var container1 = await RedisContainerFixture.CreateContainerAsync();
        await using var container2 = await RedisContainerFixture.CreateContainerAsync();
        await using var container3 = await RedisContainerFixture.CreateContainerAsync();
 
        var builder = Host.CreateEmptyApplicationBuilder(null);
        builder.Configuration.AddInMemoryCollection([
            new KeyValuePair<string, string?>("ConnectionStrings:redis1", container1.GetConnectionString()),
            new KeyValuePair<string, string?>("ConnectionStrings:redis2", container2.GetConnectionString()),
            new KeyValuePair<string, string?>("ConnectionStrings:redis3", container3.GetConnectionString())
        ]);
 
        builder.AddRedisClient("redis1");
        builder.AddKeyedRedisDistributedCache("redis2");
        builder.AddKeyedRedisOutputCache("redis3");
 
        using var host = builder.Build();
 
        var connection1 = host.Services.GetRequiredService<IConnectionMultiplexer>();
        var connection2 = host.Services.GetRequiredKeyedService<IConnectionMultiplexer>("redis2");
        var distributedCache = host.Services.GetRequiredService<IDistributedCache>();
        var connection3 = host.Services.GetRequiredKeyedService<IConnectionMultiplexer>("redis3");
        var outputCache = host.Services.GetRequiredService<IOutputCacheStore>();
 
        Assert.NotSame(connection1, connection2);
        Assert.NotSame(connection1, connection3);
        Assert.NotSame(connection2, connection3);
 
        Assert.Equal(container1.GetConnectionString(), connection1.Configuration);
        Assert.Equal(container2.GetConnectionString(), connection2.Configuration);
        Assert.Equal(container3.GetConnectionString(), connection3.Configuration);
 
        // set a value in the distributed cache and ensure it is only in the redis2 server
        distributedCache.SetString("key", "value");
 
        Assert.Empty(connection1.GetServers().Single().Keys());
        Assert.Single(connection2.GetServers().Single().Keys());
        Assert.Empty(connection3.GetServers().Single().Keys());
 
        // set a value in the output cache and ensure it was added to the redis3 server
        await outputCache.SetAsync("outputKey", [1, 2, 3, 4], tags: null, validFor: TimeSpan.MaxValue, cancellationToken: default);
 
        Assert.Empty(connection1.GetServers().Single().Keys());
        Assert.Single(connection2.GetServers().Single().Keys());
        Assert.Single(connection3.GetServers().Single().Keys());
    }
 
    private void PopulateConfiguration(ConfigurationManager configuration, string? key = null) =>
        configuration.AddInMemoryCollection([
            new KeyValuePair<string, string?>(ConformanceTests.CreateConfigKey("Aspire:StackExchange:Redis", key, "ConnectionString"), ConnectionString)
        ]);
}