File: AspireAzureAIProjectsExtensionsTests.cs
Web Access
Project: src\tests\Aspire.Azure.AI.Projects.Tests\Aspire.Azure.AI.Projects.Tests.csproj (Aspire.Azure.AI.Projects.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.ClientModel.Primitives;
using System.Text.Json;
using Aspire.Components.TestUtilities;
using Azure.AI.Projects;
using Azure.Core.Extensions;
using Json.Schema;
using Microsoft.Extensions.Azure;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using OpenTelemetry;
using OpenTelemetry.Trace;
using Xunit;
 
namespace Aspire.Azure.AI.Projects.Tests;
 
public class AspireAzureAIProjectsExtensionsTests
{
    private const string Endpoint = "https://test.services.ai.azure.com/api/projects/test";
    private const string Section = "Aspire:Azure:AI:Projects";
 
    [Fact]
    public void ValidatesArguments()
    {
        var builder = Host.CreateEmptyApplicationBuilder(null);
        Assert.Throws<ArgumentNullException>(() => AspireAzureAIProjectsExtensions.AddAzureAIProjectClient(null!, "project"));
        Assert.Throws<ArgumentNullException>(() => AspireAzureAIProjectsExtensions.AddKeyedAzureAIProjectClient(null!, "project"));
        Assert.Throws<ArgumentException>(() => builder.AddAzureAIProjectClient(""));
        Assert.Throws<ArgumentException>(() => builder.AddKeyedAzureAIProjectClient(""));
    }
 
    [Theory]
    [InlineData("""{"Endpoint":"https://test.services.ai.azure.com/api/projects/test","DisableTracing":false,"ClientOptions":{"NetworkTimeout":"00:00:30"}}""", true)]
    [InlineData("""{"Endpoint":"relative"}""", false)]
    [InlineData("""{"DisableTracing":"true"}""", false)]
    public void ConfigurationSchemaValidatesSettings(string settings, bool valid)
    {
        var schema = JsonSchema.FromFile(Path.Combine(AppContext.BaseDirectory, "ConfigurationSchema.json"),
            new BuildOptions { Dialect = Dialect.Draft07, SchemaRegistry = new SchemaRegistry() });
        using var configuration = JsonDocument.Parse("""{"Aspire":{"Azure":{"AI":{"Projects":""" + settings + "}}}}");
        Assert.Equal(valid, schema.Evaluate(configuration.RootElement, new EvaluationOptions { RequireFormatValidation = true }).IsValid);
    }
 
    [Theory]
    [InlineData(false, false, "root")]
    [InlineData(true, false, "named")]
    [InlineData(false, true, "connection")]
    [InlineData(true, true, "connection")]
    public void NamedSettingsAndConnectionStringsOverrideRoot(bool named, bool connectionString, string expected)
    {
        var builder = Host.CreateEmptyApplicationBuilder(null);
        builder.Configuration[$"{Section}:Endpoint"] = "https://root.services.ai.azure.com/api/projects/test";
        if (named)
        {
            builder.Configuration[$"{Section}:project:Endpoint"] = "https://named.services.ai.azure.com/api/projects/test";
        }
        if (connectionString)
        {
            builder.Configuration["ConnectionStrings:project"] = "Endpoint=https://connection.services.ai.azure.com/api/projects/test";
        }
 
        builder.AddAzureAIProjectClient("project", settings => Assert.Equal($"{expected}.services.ai.azure.com", settings.Endpoint!.Host));
    }
 
    [Theory]
    [InlineData(false, Endpoint)]
    [InlineData(true, Endpoint)]
    [InlineData(false, "Endpoint=" + Endpoint)]
    [InlineData(true, "Endpoint=" + Endpoint)]
    public void RegistersSingleton(bool keyed, string connectionString)
    {
        var builder = Host.CreateEmptyApplicationBuilder(null);
        builder.Configuration["ConnectionStrings:project"] = connectionString;
        Register(builder, keyed);
        using var host = builder.Build();
 
        var client = Resolve(host.Services, keyed);
        Assert.Same(client, Resolve(host.Services, keyed));
        if (keyed)
        {
            Assert.Null(host.Services.GetService<AIProjectClient>());
        }
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public void MissingEndpointFailsAtResolution(bool keyed)
    {
        var builder = Host.CreateEmptyApplicationBuilder(null);
        Register(builder, keyed);
        using var host = builder.Build();
 
        var exception = Assert.Throws<InvalidOperationException>(() => Resolve(host.Services, keyed));
        Assert.Contains("ConnectionStrings:project", exception.Message);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task ConfigurationPrecedenceAndClientCustomization(bool keyed)
    {
        var builder = Host.CreateEmptyApplicationBuilder(null);
        builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
        {
            [$"{Section}:Endpoint"] = "https://root.services.ai.azure.com/api/projects/root",
            [$"{Section}:project:Endpoint"] = "https://named.services.ai.azure.com/api/projects/named",
            ["ConnectionStrings:project"] = $"Endpoint={Endpoint}",
            [$"{Section}:ClientOptions:NetworkTimeout"] = "00:00:01",
            [$"{Section}:project:ClientOptions:NetworkTimeout"] = "00:00:02",
            [$"{Section}:project:ClientOptions:UserAgentApplicationId"] = "config-app"
        });
        var settingsCredential = new FoundryTestTokenCredential("settings-token");
        var clientCredential = new FoundryTestTokenCredential("client-token");
        using var handler = new FoundryTestHttpMessageHandler("""{"name":"agent","id":"agent","object":"agent"}""");
        using var httpClient = new HttpClient(handler);
        var finalEndpoint = new Uri("https://callback.services.ai.azure.com/api/projects/callback");
 
        void ConfigureSettings(AzureAIProjectsSettings settings)
        {
            Assert.Equal(new Uri(Endpoint), settings.Endpoint);
            settings.Endpoint = finalEndpoint;
            settings.Credential = settingsCredential;
        }
 
        void ConfigureClient(IAzureClientBuilder<AIProjectClient, AIProjectClientOptions> clientBuilder)
        {
            clientBuilder.WithCredential(clientCredential).ConfigureOptions(options =>
            {
                Assert.Equal(TimeSpan.FromSeconds(2), options.NetworkTimeout);
                Assert.Equal("config-app", options.UserAgentApplicationId);
                options.NetworkTimeout = TimeSpan.FromSeconds(3);
                options.Transport = new HttpClientPipelineTransport(httpClient);
            });
        }
 
        if (keyed)
        {
            builder.AddKeyedAzureAIProjectClient("project", ConfigureSettings, ConfigureClient);
        }
        else
        {
            builder.AddAzureAIProjectClient("project", ConfigureSettings, ConfigureClient);
        }
 
        using var host = builder.Build();
        await Resolve(host.Services, keyed).AgentAdministrationClient.GetAgentAsync("agent");
 
        var request = Assert.Single(handler.Requests);
        Assert.Equal("/api/projects/callback/agents/agent", request.Uri.AbsolutePath);
        Assert.Equal("Bearer client-token", request.Authorization);
        Assert.Contains("config-app", request.UserAgent);
        Assert.Empty(settingsCredential.RequestedScopes);
        Assert.NotEmpty(clientCredential.RequestedScopes);
        Assert.Equal(TimeSpan.FromSeconds(3), host.Services.GetRequiredService<IOptionsMonitor<AIProjectClientOptions>>().Get(keyed ? "project" : "Default").NetworkTimeout);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public void SettingsCanSupplyEndpointWithoutConnectionString(bool keyed)
    {
        var builder = Host.CreateEmptyApplicationBuilder(null);
        if (keyed)
        {
            builder.AddKeyedAzureAIProjectClient("project", settings => settings.Endpoint = new(Endpoint));
        }
        else
        {
            builder.AddAzureAIProjectClient("project", settings => settings.Endpoint = new(Endpoint));
        }
 
        using var host = builder.Build();
        Assert.NotNull(Resolve(host.Services, keyed));
    }
 
    [Theory]
    [InlineData(false, "")]
    [InlineData(true, "")]
    [InlineData(false, "Endpoint=relative")]
    [InlineData(true, "Endpoint=relative")]
    [InlineData(false, "Endpoint=https://test.services.ai.azure.com/api/projects/test;Key=secret")]
    [InlineData(true, "Endpoint=https://test.services.ai.azure.com/api/projects/test;Key=secret")]
    [InlineData(false, "ftp://test/project")]
    [InlineData(true, "ftp://test/project")]
    [InlineData(false, "http://test.services.ai.azure.com/api/projects/test")]
    [InlineData(true, "http://test.services.ai.azure.com/api/projects/test")]
    [InlineData(false, "Endpoint=http://test.services.ai.azure.com/api/projects/test")]
    [InlineData(true, "Endpoint=http://test.services.ai.azure.com/api/projects/test")]
    public void RejectsInvalidOrApiKeyConnectionStrings(bool keyed, string connectionString)
    {
        var builder = Host.CreateEmptyApplicationBuilder(null);
        builder.Configuration["ConnectionStrings:project"] = connectionString;
        Assert.Throws<ArgumentException>(() => Register(builder, keyed));
    }
 
    [Theory]
    [InlineData(false, false)]
    [InlineData(true, false)]
    [InlineData(false, true)]
    [InlineData(true, true)]
    public void RejectsHttpEndpointsFromSettings(bool keyed, bool configureSettings)
    {
        var builder = Host.CreateEmptyApplicationBuilder(null);
        var endpoint = new Uri("http://test.services.ai.azure.com/api/projects/test");
        var credential = new FoundryTestTokenCredential();
        if (!configureSettings)
        {
            builder.Configuration[keyed ? $"{Section}:project:Endpoint" : $"{Section}:Endpoint"] = endpoint.AbsoluteUri;
        }
 
        void ConfigureSettings(AzureAIProjectsSettings settings)
        {
            settings.Credential = credential;
            if (configureSettings)
            {
                settings.Endpoint = endpoint;
            }
        }
 
        if (keyed)
        {
            builder.AddKeyedAzureAIProjectClient("project", ConfigureSettings);
        }
        else
        {
            builder.AddAzureAIProjectClient("project", ConfigureSettings);
        }
 
        using var host = builder.Build();
        var exception = Assert.Throws<ArgumentException>(() => Resolve(host.Services, keyed));
        Assert.Equal(nameof(endpoint), exception.ParamName);
        Assert.Contains("absolute HTTPS URI", exception.Message);
        Assert.Empty(credential.RequestedScopes);
    }
 
    [Fact]
    public void KeyedClientsAreIndependent()
    {
        var builder = Host.CreateEmptyApplicationBuilder(null);
        builder.AddKeyedAzureAIProjectClient("first", settings => settings.Endpoint = new(Endpoint));
        builder.AddKeyedAzureAIProjectClient("second", settings => settings.Endpoint = new(Endpoint));
        using var host = builder.Build();
 
        Assert.NotSame(host.Services.GetRequiredKeyedService<AIProjectClient>("first"), host.Services.GetRequiredKeyedService<AIProjectClient>("second"));
    }
 
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task ExportsSdkActivitiesWhenTracingEnabled(bool enabled)
    {
        var builder = Host.CreateEmptyApplicationBuilder(null);
        using var handler = new FoundryTestHttpMessageHandler("""{"name":"connection","id":"connection","type":"AzureOpenAI","target":"https://test.openai.azure.com","isDefault":false,"metadata":{}}""");
        using var httpClient = new HttpClient(handler);
        using var exporter = new FoundryTestActivityExporter();
        builder.AddAzureAIProjectClient("project", settings =>
        {
            settings.Endpoint = new(Endpoint);
            settings.Credential = new FoundryTestTokenCredential();
            settings.DisableTracing = !enabled;
        }, client => client.ConfigureOptions(options => options.Transport = new HttpClientPipelineTransport(httpClient)));
        builder.Services.AddOpenTelemetry().WithTracing(tracing => tracing.AddProcessor(new SimpleActivityExportProcessor(exporter)));
        using var host = builder.Build();
        host.Services.GetRequiredService<TracerProvider>();
 
        await host.Services.GetRequiredService<AIProjectClient>().Connections.GetConnectionAsync("connection");
 
        if (enabled)
        {
            Assert.Contains(exporter.Activities, activity => activity.Source.Name.StartsWith("Azure.AI.Projects.", StringComparison.Ordinal));
        }
        else
        {
            Assert.Empty(exporter.Activities);
        }
    }
 
    private static void Register(HostApplicationBuilder builder, bool keyed)
    {
        if (keyed)
        {
            builder.AddKeyedAzureAIProjectClient("project");
        }
        else
        {
            builder.AddAzureAIProjectClient("project");
        }
    }
 
    private static AIProjectClient Resolve(IServiceProvider services, bool keyed)
        => keyed ? services.GetRequiredKeyedService<AIProjectClient>("project") : services.GetRequiredService<AIProjectClient>();
}