File: ResourceOutgoingPeerResolverTests.cs
Web Access
Project: src\tests\Aspire.Dashboard.Tests\Aspire.Dashboard.Tests.csproj (Aspire.Dashboard.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 System.Threading.Channels;
using Aspire.Dashboard.Model;
using Aspire.DashboardService.Proto.V1;
using Aspire.Tests.Shared.DashboardModel;
using Microsoft.AspNetCore.InternalTesting;
using Xunit;
using Value = Google.Protobuf.WellKnownTypes.Value;
 
namespace Aspire.Dashboard.Tests;
 
public class ResourceOutgoingPeerResolverTests
{
    private static ResourceViewModel CreateResource(string name, string? serviceAddress = null, int? servicePort = null, string? displayName = null, KnownResourceState? state = null)
    {
        return ModelTestHelpers.CreateResource(
            appName: name,
            displayName: displayName,
            state: state,
            urls: serviceAddress is null || servicePort is null ? [] : [new UrlViewModel(name, new($"http://{serviceAddress}:{servicePort}"), isInternal: false, isInactive: false, displayProperties: UrlDisplayPropertiesViewModel.Empty)]);
    }
 
    [Fact]
    public void EmptyAttributes_NoMatch()
    {
        // Arrange
        var resources = new Dictionary<string, ResourceViewModel>
        {
            ["test"] = CreateResource("test", "localhost", 5000)
        };
 
        // Act & Assert
        Assert.False(TryResolvePeerName(resources, [], out _));
    }
 
    [Fact]
    public void EmptyUrlAttribute_NoMatch()
    {
        // Arrange
        var resources = new Dictionary<string, ResourceViewModel>
        {
            ["test"] = CreateResource("test", "localhost", 5000)
        };
 
        // Act & Assert
        Assert.False(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "")], out _));
    }
 
    [Fact]
    public void NullUrlAttribute_NoMatch()
    {
        // Arrange
        var resources = new Dictionary<string, ResourceViewModel>
        {
            ["test"] = CreateResource("test", "localhost", 5000)
        };
 
        // Act & Assert
        Assert.False(TryResolvePeerName(resources, [KeyValuePair.Create<string, string>("peer.service", null!)], out _));
    }
 
    [Fact]
    public void ExactValueAttribute_Match()
    {
        // Arrange
        var resources = new Dictionary<string, ResourceViewModel>
        {
            ["test"] = CreateResource("test", "localhost", 5000)
        };
 
        // Act & Assert
        Assert.True(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "localhost:5000")], out var value));
        Assert.Equal("test", value);
    }
 
    [Fact]
    public void NumberAddressValueAttribute_Match()
    {
        // Arrange
        var resources = new Dictionary<string, ResourceViewModel>
        {
            ["test"] = CreateResource("test", "localhost", 5000)
        };
 
        // Act & Assert
        Assert.True(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "127.0.0.1:5000")], out var value));
        Assert.Equal("test", value);
    }
 
    [Fact]
    public void CommaAddressValueAttribute_Match()
    {
        // Arrange
        var resources = new Dictionary<string, ResourceViewModel>
        {
            ["test"] = CreateResource("test", "localhost", 5000)
        };
 
        // Act & Assert
        Assert.True(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "127.0.0.1,5000")], out var value));
        Assert.Equal("test", value);
    }
 
    [Fact]
    public void ServerAddressAndPort_Match()
    {
        // Arrange
        var resources = new Dictionary<string, ResourceViewModel>
        {
            ["test"] = CreateResource("test", "localhost", 5000)
        };
 
        // Act & Assert
        Assert.True(TryResolvePeerName(resources, [KeyValuePair.Create("server.address", "localhost"), KeyValuePair.Create("server.port", "5000")], out var value));
        Assert.Equal("test", value);
    }
 
    [Fact]
    public async Task OnPeerChanges_DataUpdates_EventRaised()
    {
        // Arrange
        var tcs = new TaskCompletionSource<ResourceViewModelSubscription>(TaskCreationOptions.RunContinuationsAsynchronously);
        var sourceChannel = Channel.CreateUnbounded<ResourceViewModelChange>();
        var resultChannel = Channel.CreateUnbounded<int>();
        var dashboardClient = new MockDashboardClient(tcs.Task);
        var resolver = new ResourceOutgoingPeerResolver(dashboardClient);
        var changeCount = 0;
        resolver.OnPeerChanges(async () =>
        {
            await resultChannel.Writer.WriteAsync(++changeCount);
        });
 
        var readValue = 0;
        Assert.False(resultChannel.Reader.TryRead(out readValue));
 
        // Act 1
        // Initial resource causes change.
        tcs.SetResult(new ResourceViewModelSubscription(
            [CreateResource("test", serviceAddress: "localhost", servicePort: 8080)],
            GetChanges()));
 
        // Assert 1
        readValue = await resultChannel.Reader.ReadAsync().DefaultTimeout();
        Assert.Equal(1, readValue);
 
        // Act 2
        // New resource causes change.
        await sourceChannel.Writer.WriteAsync(new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, CreateResource("test2", serviceAddress: "localhost", servicePort: 8080, state: KnownResourceState.Starting)));
 
        // Assert 2
        readValue = await resultChannel.Reader.ReadAsync().DefaultTimeout();
        Assert.Equal(2, readValue);
 
        // Act 3
        // URL change causes change.
        await sourceChannel.Writer.WriteAsync(new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, CreateResource("test2", serviceAddress: "localhost", servicePort: 8081, state: KnownResourceState.Starting)));
 
        // Assert 3
        readValue = await resultChannel.Reader.ReadAsync().DefaultTimeout();
        Assert.Equal(3, readValue);
 
        // Act 4
        // Resource update doesn't cause change.
        await sourceChannel.Writer.WriteAsync(new ResourceViewModelChange(ResourceViewModelChangeType.Upsert, CreateResource("test2", serviceAddress: "localhost", servicePort: 8081, state: KnownResourceState.Running)));
 
        // Dispose so that we know that all changes are processed.
        await resolver.DisposeAsync().DefaultTimeout();
        resultChannel.Writer.Complete();
 
        // Assert 4
        Assert.False(await resultChannel.Reader.WaitToReadAsync().DefaultTimeout());
        Assert.Equal(3, changeCount);
 
        async IAsyncEnumerable<IReadOnlyList<ResourceViewModelChange>> GetChanges([EnumeratorCancellation] CancellationToken cancellationToken = default)
        {
            await foreach (var item in sourceChannel.Reader.ReadAllAsync(cancellationToken))
            {
                yield return [item];
            }
        }
    }
 
    [Fact]
    public void NameAndDisplayNameDifferent_OneInstance_ReturnDisplayName()
    {
        // Arrange
        var resources = new Dictionary<string, ResourceViewModel>
        {
            ["test-abc"] = CreateResource("test-abc", "localhost", 5000, displayName: "test")
        };
 
        // Act & Assert
        Assert.True(TryResolvePeerName(resources, [KeyValuePair.Create("server.address", "localhost"), KeyValuePair.Create("server.port", "5000")], out var value));
        Assert.Equal("test", value);
    }
 
    [Fact]
    public void NameAndDisplayNameDifferent_MultipleInstances_ReturnName()
    {
        // Arrange
        var resources = new Dictionary<string, ResourceViewModel>
        {
            ["test-abc"] = CreateResource("test-abc", "localhost", 5000, displayName: "test"),
            ["test-def"] = CreateResource("test-def", "localhost", 5001, displayName: "test")
        };
 
        // Act & Assert
        Assert.True(TryResolvePeerName(resources, [KeyValuePair.Create("server.address", "localhost"), KeyValuePair.Create("server.port", "5000")], out var value1));
        Assert.Equal("test-abc", value1);
 
        Assert.True(TryResolvePeerName(resources, [KeyValuePair.Create("server.address", "localhost"), KeyValuePair.Create("server.port", "5001")], out var value2));
        Assert.Equal("test-def", value2);
    }
 
    private static bool TryResolvePeerName(IDictionary<string, ResourceViewModel> resources, KeyValuePair<string, string>[] attributes, out string? peerName)
    {
        return ResourceOutgoingPeerResolver.TryResolvePeerCore(resources, attributes, out peerName, out _);
    }
 
    [Fact]
    public void ConnectionStringWithEndpoint_Match()
    {
        // Arrange - GitHub Models resource with connection string containing endpoint
        var connectionString = "Endpoint=https://models.github.ai/inference;Key=test-key;Model=openai/gpt-4o-mini;DeploymentId=openai/gpt-4o-mini";
        var resources = new Dictionary<string, ResourceViewModel>
        {
            ["github-model"] = CreateResourceWithConnectionString("github-model", connectionString)
        };
 
        // Act & Assert
        Assert.True(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "models.github.ai:443")], out var value));
        Assert.Equal("github-model", value);
    }
 
    [Fact]
    public void ConnectionStringWithEndpointOrganization_Match()
    {
        // Arrange - GitHub Models resource with organization endpoint
        var connectionString = "Endpoint=https://models.github.ai/orgs/myorg/inference;Key=test-key;Model=openai/gpt-4o-mini;DeploymentId=openai/gpt-4o-mini";
        var resources = new Dictionary<string, ResourceViewModel>
        {
            ["github-model"] = CreateResourceWithConnectionString("github-model", connectionString)
        };
 
        // Act & Assert
        Assert.True(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "models.github.ai:443")], out var value));
        Assert.Equal("github-model", value);
    }
 
    [Fact]
    public void ParameterWithUrlValue_Match()
    {
        // Arrange - Parameter resource with URL value
        var resources = new Dictionary<string, ResourceViewModel>
        {
            ["api-url-param"] = CreateResourceWithParameterValue("api-url-param", "https://api.example.com:8080/endpoint")
        };
 
        // Act & Assert
        Assert.True(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "api.example.com:8080")], out var value));
        Assert.Equal("api-url-param", value);
    }
 
    [Fact]
    public void ConnectionStringWithoutEndpoint_NoMatch()
    {
        // Arrange - Connection string without Endpoint property
        var connectionString = "Server=localhost;Database=test;User=admin;Password=secret";
        var resources = new Dictionary<string, ResourceViewModel>
        {
            ["sql-connection"] = CreateResourceWithConnectionString("sql-connection", connectionString)
        };
 
        // Act & Assert
        Assert.False(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "localhost:1433")], out _));
    }
 
    [Fact]
    public void ParameterWithNonUrlValue_NoMatch()
    {
        // Arrange - Parameter resource with non-URL value
        var resources = new Dictionary<string, ResourceViewModel>
        {
            ["config-param"] = CreateResourceWithParameterValue("config-param", "simple-config-value")
        };
 
        // Act & Assert
        Assert.False(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "localhost:5000")], out _));
    }
 
    [Fact]
    public void ConnectionStringAsDirectUrl_Match()
    {
        // Arrange - Connection string that is itself a URL (e.g., blob storage)
        var connectionString = "https://mystorageaccount.blob.core.windows.net/";
        var resources = new Dictionary<string, ResourceViewModel>
        {
            ["blob-storage"] = CreateResourceWithConnectionString("blob-storage", connectionString)
        };
 
        // Act & Assert
        Assert.True(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "mystorageaccount.blob.core.windows.net:443")], out var value));
        Assert.Equal("blob-storage", value);
    }
 
    [Fact]
    public void ConnectionStringAsDirectUrlWithCustomPort_Match()
    {
        // Arrange - Connection string that is itself a URL with custom port
        var connectionString = "https://myvault.vault.azure.net:8080/";
        var resources = new Dictionary<string, ResourceViewModel>
        {
            ["key-vault"] = CreateResourceWithConnectionString("key-vault", connectionString)
        };
 
        // Act & Assert
        Assert.True(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "myvault.vault.azure.net:8080")], out var value));
        Assert.Equal("key-vault", value);
    }
 
    private static ResourceViewModel CreateResourceWithConnectionString(string name, string connectionString)
    {
        var properties = new Dictionary<string, ResourcePropertyViewModel>
        {
            [KnownProperties.Resource.ConnectionString] = new(
                name: KnownProperties.Resource.ConnectionString,
                value: Value.ForString(connectionString),
                isValueSensitive: false,
                knownProperty: null,
                priority: 0)
        };
 
        return ModelTestHelpers.CreateResource(
            appName: name,
            resourceType: KnownResourceTypes.ConnectionString,
            properties: properties);
    }
 
    private static ResourceViewModel CreateResourceWithParameterValue(string name, string value)
    {
        var properties = new Dictionary<string, ResourcePropertyViewModel>
        {
            [KnownProperties.Parameter.Value] = new(
                name: KnownProperties.Parameter.Value,
                value: Value.ForString(value),
                isValueSensitive: false,
                knownProperty: null,
                priority: 0)
        };
 
        return ModelTestHelpers.CreateResource(
            appName: name,
            resourceType: KnownResourceTypes.Parameter,
            properties: properties);
    }
 
    [Fact]
    public void MultipleResourcesMatch_ViaTransformation_ReturnsFirstMatch()
    {
        // Arrange - Resources that match via address transformation
        var resources = new Dictionary<string, ResourceViewModel>
        {
            ["sql-primary"] = CreateResource("sql-primary", "localhost", 1433),
            ["sql-replica"] = CreateResource("sql-replica", "127.0.0.1", 1433)
        };
 
        // Act & Assert - Should return the first match found
        Assert.True(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "127.0.0.1:1433")], out var name));
        Assert.Equal("sql-replica", name);
    }
 
    [Fact]
    public void MultipleResourcesSameAddress_ReturnsFirstMatch()
    {
        // Test to verify that "first one wins" logic is restored
        var resources = new Dictionary<string, ResourceViewModel>
        {
            ["database1"] = CreateResource("database1", "localhost", 5432),
            ["database2"] = CreateResource("database2", "localhost", 5432)
        };
 
        // Should return the first match found (verifies regression fix)
        Assert.True(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "localhost:5432")], out var name));
        Assert.NotNull(name);
        // Should match one of the databases (first one found)
        Assert.Contains(name, new[] { "database1", "database2" });
    }
 
    [Fact]
    public void SingleResourceAfterTransformation_ReturnsTrue()
    {
        // Arrange - Only one resource that matches after address transformation
        var resources = new Dictionary<string, ResourceViewModel>
        {
            ["unique-service"] = CreateResource("unique-service", "localhost", 8080),
            ["other-service"] = CreateResource("other-service", "remotehost", 9090)
        };
 
        // Act & Assert - Only the first resource should match "127.0.0.1:8080" after transformation
        Assert.True(TryResolvePeerName(resources, [KeyValuePair.Create("peer.service", "127.0.0.1:8080")], out var name));
        Assert.Equal("unique-service", name);
    }
 
    private sealed class MockDashboardClient(Task<ResourceViewModelSubscription> subscribeResult) : IDashboardClient
    {
        public bool IsEnabled => true;
        public Task WhenConnected => Task.CompletedTask;
        public string ApplicationName => "ApplicationName";
        public ValueTask DisposeAsync() => ValueTask.CompletedTask;
        public Task<ResourceCommandResponseViewModel> ExecuteResourceCommandAsync(string resourceName, string resourceType, CommandViewModel command, CancellationToken cancellationToken) => throw new NotImplementedException();
        public IAsyncEnumerable<IReadOnlyList<ResourceLogLine>> GetConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException();
        public ResourceViewModel? GetResource(string resourceName) => throw new NotImplementedException();
        public Task SendInteractionRequestAsync(WatchInteractionsRequestUpdate request, CancellationToken cancellationToken) => throw new NotImplementedException();
        public IAsyncEnumerable<IReadOnlyList<ResourceLogLine>> SubscribeConsoleLogs(string resourceName, CancellationToken cancellationToken) => throw new NotImplementedException();
        public IAsyncEnumerable<WatchInteractionsResponseUpdate> SubscribeInteractionsAsync(CancellationToken cancellationToken) => throw new NotImplementedException();
        public Task<ResourceViewModelSubscription> SubscribeResourcesAsync(CancellationToken cancellationToken) => subscribeResult;
    }
}