File: Integration\HealthTests.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.Diagnostics;
using System.Net;
using Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using OpenTelemetry;
using OpenTelemetry.Trace;
using Xunit;
 
namespace Aspire.Dashboard.Tests.Integration;
 
public class HealthTests(HealthTests.Fixture fixture) : IClassFixture<HealthTests.Fixture>
{
    private const string TestActivitySourceName = "Aspire.Dashboard.Tests.Integration.HealthTests";
 
    [Fact]
    public async Task HealthEndpoint_SendRequest_200Response()
    {
        await MakeRequestAndAssert($"http://{fixture.App.FrontendSingleEndPointAccessor().EndPoint}", HttpVersion.Version11).DefaultTimeout();
        await MakeRequestAndAssert($"http://{fixture.App.OtlpServiceHttpEndPointAccessor().EndPoint}", HttpVersion.Version11).DefaultTimeout();
        await MakeRequestAndAssert($"http://{fixture.App.OtlpServiceGrpcEndPointAccessor().EndPoint}", HttpVersion.Version20).DefaultTimeout();
 
        static async Task MakeRequestAndAssert(string basePath, Version httpVersion)
        {
            using var httpClientHandler = new HttpClientHandler { AllowAutoRedirect = false };
            using var client = new HttpClient(httpClientHandler) { BaseAddress = new Uri(basePath) };
 
            // Act
            var request = new HttpRequestMessage(HttpMethod.Get, $"/{DashboardUrls.HealthBasePath}");
            request.Version = httpVersion;
            request.VersionPolicy = HttpVersionPolicy.RequestVersionExact;
            var response = await client.SendAsync(request).DefaultTimeout();
 
            // Assert 
            Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        }
    }
 
    [Fact]
    public async Task HealthEndpoint_OtlpExporterConfigured_ExportsAspNetCoreActivity()
    {
        using var testActivity = fixture.TestActivitySource.StartActivity(nameof(HealthEndpoint_OtlpExporterConfigured_ExportsAspNetCoreActivity));
        Assert.NotNull(testActivity);
        using var observation = fixture.Exporter.ObserveNext(
            testActivity.TraceId,
            activity => activity.Source.Name == "Microsoft.AspNetCore" && activity.Kind == ActivityKind.Server);
 
        using var client = new HttpClient { BaseAddress = new Uri($"http://{fixture.App.FrontendSingleEndPointAccessor().EndPoint}") };
        var response = await client.GetAsync($"/{DashboardUrls.HealthBasePath}").DefaultTimeout();
 
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        var activity = await observation.Task.DefaultTimeout();
        Assert.Equal(testActivity.TraceId, activity.TraceId);
        Assert.Equal("Microsoft.AspNetCore", activity.Source.Name);
        Assert.Equal(ActivityKind.Server, activity.Kind);
    }
 
    [Fact]
    public async Task OtlpExporterConfigured_ExportsResourceServiceActivity()
    {
        using var testActivity = fixture.TestActivitySource.StartActivity(nameof(OtlpExporterConfigured_ExportsResourceServiceActivity));
        Assert.NotNull(testActivity);
        using var observation = fixture.Exporter.ObserveNext(
            testActivity.TraceId,
            activity => activity.Source.Name == DashboardActivitySource.ActivitySourceName);
        var activitySource = fixture.App.Services.GetRequiredService<DashboardActivitySource>();
 
        using (var activity = activitySource.ActivitySource.StartActivity("Test resource update", ActivityKind.Consumer))
        {
            Assert.NotNull(activity);
        }
 
        var exported = await observation.Task.DefaultTimeout();
        Assert.Equal(testActivity.TraceId, exported.TraceId);
        Assert.Equal(DashboardActivitySource.ActivitySourceName, exported.Source.Name);
        Assert.Equal(ActivityKind.Consumer, exported.Kind);
    }
 
    public sealed class Fixture : IAsyncLifetime
    {
        public DashboardWebApplication App { get; private set; } = null!;
        internal TestActivityExporter Exporter { get; } = new();
        internal ActivitySource TestActivitySource { get; } = new(TestActivitySourceName);
 
        public async ValueTask InitializeAsync()
        {
            App = IntegrationTestHelpers.CreateDashboardWebApplication(
                NullLoggerFactory.Instance,
                config => config["OTEL_EXPORTER_OTLP_ENDPOINT"] = "http://127.0.0.1:1",
                builder => builder.Services.AddOpenTelemetry()
                    .WithTracing(tracing => tracing
                        .AddSource(TestActivitySourceName)
                        .AddProcessor(new SimpleActivityExportProcessor(Exporter))));
            await App.StartAsync().DefaultTimeout();
        }
 
        public async ValueTask DisposeAsync()
        {
            await App.DisposeAsync();
            TestActivitySource.Dispose();
        }
    }
 
    internal sealed class TestActivityExporter : BaseExporter<Activity>
    {
        private readonly object _lock = new();
        private Observation? _observation;
 
        public Observation ObserveNext(ActivityTraceId traceId, Func<Activity, bool> predicate)
        {
            var observation = new Observation(this, traceId, predicate);
            lock (_lock)
            {
                Assert.Null(_observation);
                _observation = observation;
            }
            return observation;
        }
 
        public override ExportResult Export(in Batch<Activity> batch)
        {
            foreach (var activity in batch)
            {
                lock (_lock)
                {
                    if (_observation is { } observation &&
                        observation.TraceId == activity.TraceId &&
                        observation.Predicate(activity))
                    {
                        _observation = null;
                        observation.TrySetResult(activity);
                    }
                }
            }
 
            return ExportResult.Success;
        }
 
        private void Remove(Observation observation)
        {
            lock (_lock)
            {
                if (ReferenceEquals(_observation, observation))
                {
                    _observation = null;
                }
            }
        }
 
        internal sealed class Observation(TestActivityExporter exporter, ActivityTraceId traceId, Func<Activity, bool> predicate) : IDisposable
        {
            private readonly TaskCompletionSource<Activity> _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
 
            public ActivityTraceId TraceId { get; } = traceId;
            public Func<Activity, bool> Predicate { get; } = predicate;
            public Task<Activity> Task => _completion.Task;
 
            public void Dispose() => exporter.Remove(this);
 
            public void TrySetResult(Activity activity) => _completion.TrySetResult(activity);
        }
    }
}