File: WithHttpCommandTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.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.Net;
using System.Net.Http.Headers;
using System.Text;
using Aspire.Hosting.Testing;
using Aspire.Hosting.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http.Resilience;
using Microsoft.Extensions.Options;
 
namespace Aspire.Hosting.Tests;
 
[Trait("Partition", "6")]
public class WithHttpCommandTests(ITestOutputHelper testOutputHelper)
{
    [Fact]
    public void HttpCommandResultMode_HasExpectedOrdering()
    {
        Assert.Equal(0, (int)HttpCommandResultMode.None);
        Assert.Equal(1, (int)HttpCommandResultMode.Auto);
        Assert.Equal(2, (int)HttpCommandResultMode.Json);
        Assert.Equal(3, (int)HttpCommandResultMode.Text);
    }
 
    [Theory]
    [InlineData(null, false, null)]
    [InlineData("application/octet-stream", false, null)]
    [InlineData("application/json", true, CommandResultFormat.Json)]
    [InlineData("application/problem+json", true, CommandResultFormat.Json)]
    [InlineData("text/plain", true, CommandResultFormat.Text)]
    [InlineData("application/xml", true, CommandResultFormat.Text)]
    [InlineData("application/problem+xml", true, CommandResultFormat.Text)]
    [InlineData("application/x-www-form-urlencoded", true, CommandResultFormat.Text)]
    public void TryInferHttpCommandResultFormat_ReturnsExpectedResult(string? mediaType, bool expectedSuccess, CommandResultFormat? expectedFormat)
    {
        var contentType = mediaType is null ? null : MediaTypeHeaderValue.Parse(mediaType);
 
        var success = ResourceBuilderExtensions.TryInferHttpCommandResultFormat(contentType, out var resultFormat);
 
        Assert.Equal(expectedSuccess, success);
        Assert.Equal(expectedFormat, success ? resultFormat : null);
    }
 
    [Fact]
    public async Task GetDefaultHttpCommandResultAsync_WithoutOptIn_DoesNotReturnResponseBody()
    {
        using var response = new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent("""{"token":"abc123"}""", Encoding.UTF8, "application/json")
        };
 
        var result = await ResourceBuilderExtensions.GetDefaultHttpCommandResultAsync(response, new HttpCommandOptions(), CancellationToken.None);
 
        Assert.True(result.Success);
        Assert.Null(result.Message);
        Assert.Null(result.Data);
    }
 
    [Fact]
    public async Task GetDefaultHttpCommandResultAsync_WithResultModeJson_ReturnsResponseBody()
    {
        using var response = new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent("""{"token":"abc123"}""", Encoding.UTF8, "application/json")
        };
 
        var result = await ResourceBuilderExtensions.GetDefaultHttpCommandResultAsync(response, new HttpCommandOptions
        {
            ResultMode = HttpCommandResultMode.Json
        }, CancellationToken.None);
 
        Assert.True(result.Success);
        Assert.Null(result.Message);
        Assert.Equal("""{"token":"abc123"}""", result.Data?.Value);
        Assert.Equal(CommandResultFormat.Json, result.Data?.Format);
    }
 
    [Fact]
    public async Task GetDefaultHttpCommandResultAsync_WithResultModeAuto_ReturnsErrorBodyUsingInferredFormat()
    {
        using var response = new HttpResponseMessage(HttpStatusCode.BadRequest)
        {
            Content = new StringContent("invalid request", Encoding.UTF8, "text/plain")
        };
 
        var result = await ResourceBuilderExtensions.GetDefaultHttpCommandResultAsync(response, new HttpCommandOptions
        {
            ResultMode = HttpCommandResultMode.Auto
        }, CancellationToken.None);
 
        Assert.False(result.Success);
        Assert.Equal("Request failed with status code BadRequest", result.Message);
        Assert.Equal("invalid request", result.Data?.Value);
        Assert.Equal(CommandResultFormat.Text, result.Data?.Format);
    }
 
    [Fact]
    public void WithHttpCommand_AddsHttpClientFactory()
    {
        // Arrange
        var builder = DistributedApplication.CreateBuilder();
 
        // Act
        var resourceBuilder = builder.AddContainer("name", "image")
            .WithHttpEndpoint()
            .WithHttpCommand("/some-path", "Do The Thing");
 
        using var app = builder.Build();
 
        // Assert
        var httpClientFactoryServiceDescriptor = builder.Services.FirstOrDefault(sd => sd.ServiceType == typeof(IHttpClientFactory));
        Assert.NotNull(httpClientFactoryServiceDescriptor);
 
        var httpClientFactory = app.Services.GetService<IHttpClientFactory>();
        Assert.NotNull(httpClientFactory);
    }
 
    [Fact]
    public async Task WithHttpCommand_WithoutNamedHttpClient_HasNoTimeout()
    {
        using var builder = CreateTestDistributedApplicationBuilder();
        var fakeHandler = new FakeHttpMessageHandler(HttpStatusCode.OK);
        builder.Services.AddHttpClient(Options.DefaultName)
            .ConfigureHttpClient(client => client.Timeout = TimeSpan.FromSeconds(100))
            .ConfigurePrimaryHttpMessageHandler(() => fakeHandler);
 
        TimeSpan? timeout = null;
        var service = CreateResourceWithAllocatedEndpoint(builder, "service");
        service.WithHttpCommand("/some-path", "Do The Thing", commandName: "mycommand", commandOptions: new()
        {
            PrepareRequest = context =>
            {
                timeout = context.HttpClient.Timeout;
                return Task.CompletedTask;
            }
        });
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
        await MoveResourceToRunningStateAsync(app, service.Resource);
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(service.Resource, "mycommand").DefaultTimeout();
 
        Assert.True(result.Success);
        Assert.Equal(Timeout.InfiniteTimeSpan, timeout);
    }
 
    [Fact]
    public void WithHttpCommand_Throws_WhenEndpointByNameIsNotHttp()
    {
        // Arrange
        using var builder = CreateTestDistributedApplicationBuilder();
 
        var container = builder.AddContainer("name", "image")
                .WithEndpoint(targetPort: 9999, scheme: "tcp", name: "nonhttp");
 
        // Act
        var ex = Assert.Throws<DistributedApplicationException>(() =>
        {
            container.WithHttpCommand("/some-path", "Do The Thing", endpointName: "nonhttp");
        });
 
        // Assert
        Assert.Equal(
            "Could not create HTTP command for resource 'name' as the endpoint with name 'nonhttp' and scheme 'tcp' is not an HTTP endpoint.",
            ex.Message
        );
    }
 
    [Fact]
    public void WithHttpCommand_Throws_WhenEndpointIsNotHttp()
    {
        // Arrange
        using var builder = CreateTestDistributedApplicationBuilder();
 
        var container = builder.AddContainer("name", "image")
                .WithEndpoint(targetPort: 9999, scheme: "tcp", name: "nonhttp");
 
        // Act
        var ex = Assert.Throws<DistributedApplicationException>(() =>
        {
            container.WithHttpCommand("/some-path", "Do The Thing", () => container.GetEndpoint("nonhttp"));
        });
 
        // Assert
        Assert.Equal(
            "Could not create HTTP command for resource 'name' as the endpoint with name 'nonhttp' and scheme 'tcp' is not an HTTP endpoint.",
            ex.Message
        );
    }
 
    [Fact]
    public void WithHttpCommand_AddsResourceCommandAnnotation_WithDefaultValues()
    {
        // Arrange
        using var builder = CreateTestDistributedApplicationBuilder();
        var resourceBuilder = builder.AddContainer("name", "image")
            .WithHttpEndpoint()
            .WithHttpCommand("/some-path", "Do The Thing");
 
        // Act
        var command = resourceBuilder.Resource.Annotations.OfType<ResourceCommandAnnotation>().FirstOrDefault();
 
        // Assert
        Assert.NotNull(command);
        Assert.Equal("Do The Thing", command.DisplayName);
        // Expected name format: "{endpoint.Resource.Name}-{endpoint.EndpointName}-http-{httpMethod}"
        Assert.Equal($"{resourceBuilder.Resource.Name}-http-http-post-/some-path", command.Name);
        Assert.Null(command.DisplayDescription);
        Assert.Null(command.ConfirmationMessage);
        Assert.Null(command.IconName);
        Assert.Null(command.IconVariant);
        Assert.False(command.IsHighlighted);
    }
 
    [Fact]
    public void WithHttpCommand_AddsResourceCommandAnnotation_WithCustomValues()
    {
        // Arrange
        using var builder = CreateTestDistributedApplicationBuilder();
        var resourceBuilder = builder.AddContainer("name", "image")
            .WithHttpEndpoint()
            .WithHttpCommand("/some-path", "Do The Thing",
                commandName: "my-command-name",
                commandOptions: new()
                {
                    Description = "Command description",
                    ConfirmationMessage = "Are you sure?",
                    IconName = "DatabaseLightning",
                    IconVariant = IconVariant.Filled,
                    IsHighlighted = true
                });
 
        // Act
        var command = resourceBuilder.Resource.Annotations.OfType<ResourceCommandAnnotation>().FirstOrDefault();
 
        // Assert
        Assert.NotNull(command);
        Assert.Equal("Do The Thing", command.DisplayName);
        Assert.Equal("my-command-name", command.Name);
        Assert.Equal("Command description", command.DisplayDescription);
        Assert.Equal("Are you sure?", command.ConfirmationMessage);
        Assert.Equal("DatabaseLightning", command.IconName);
        Assert.Equal(IconVariant.Filled, command.IconVariant);
        Assert.True(command.IsHighlighted);
    }
 
    [Fact]
    public void WithHttpCommandExport_CopiesCommonCommandOptions()
    {
        using var builder = CreateTestDistributedApplicationBuilder();
        var resourceBuilder = builder.AddResource(new CustomResource("name"))
            .WithHttpEndpoint()
            .WithHttpCommandExport("/some-path", "Do The Thing", new HttpCommandExportOptions
            {
                CommandOptions = new CommandOptions
                {
                    Description = "Command description",
                    ConfirmationMessage = "Are you sure?",
                    IconName = "DatabaseLightning",
                    IconVariant = IconVariant.Filled,
                    IsHighlighted = true,
                    Visibility = ResourceCommandVisibility.Api,
                    Arguments =
                    [
                        new InteractionInput
                        {
                            Name = "message",
                            InputType = InputType.Text,
                            Required = true
                        }
                    ]
                }
            });
 
        var command = resourceBuilder.Resource.Annotations.OfType<ResourceCommandAnnotation>().Single();
        var argument = Assert.Single(command.Arguments);
 
        Assert.Equal("Do The Thing", command.DisplayName);
        Assert.Equal("Command description", command.DisplayDescription);
        Assert.Equal("Are you sure?", command.ConfirmationMessage);
        Assert.Equal("DatabaseLightning", command.IconName);
        Assert.Equal(IconVariant.Filled, command.IconVariant);
        Assert.True(command.IsHighlighted);
        Assert.Equal(ResourceCommandVisibility.Api, command.Visibility);
        Assert.Equal("message", argument.Name);
        Assert.True(argument.Required);
    }
 
    [Fact]
    public void WithHttpCommandExport_PrefersTopLevelOptionsOverCommonCommandOptions()
    {
        using var builder = CreateTestDistributedApplicationBuilder();
        var resourceBuilder = builder.AddResource(new CustomResource("name"))
            .WithHttpEndpoint()
            .WithHttpCommandExport("/some-path", "Do The Thing", new HttpCommandExportOptions
            {
                Description = "Top-level description",
                ConfirmationMessage = "Top-level confirmation",
                IconName = "TopLevelIcon",
                IconVariant = IconVariant.Filled,
                IsHighlighted = true,
                CommandOptions = new CommandOptions
                {
                    Description = "Common description",
                    ConfirmationMessage = "Common confirmation",
                    IconName = "CommonIcon",
                    IconVariant = IconVariant.Regular
                }
            });
 
        var command = resourceBuilder.Resource.Annotations.OfType<ResourceCommandAnnotation>().Single();
 
        Assert.Equal("Top-level description", command.DisplayDescription);
        Assert.Equal("Top-level confirmation", command.ConfirmationMessage);
        Assert.Equal("TopLevelIcon", command.IconName);
        Assert.Equal(IconVariant.Filled, command.IconVariant);
        Assert.True(command.IsHighlighted);
    }
 
    [Fact]
    public async Task WithHttpCommandExport_UsesPrepareRequestCallbackProperty()
    {
        using var builder = CreateTestDistributedApplicationBuilder();
 
        var fakeHandler = new FakeHttpMessageHandler(HttpStatusCode.OK, "ok", "text/plain");
        builder.Services.AddHttpClient(string.Empty)
            .ConfigurePrimaryHttpMessageHandler(() => fakeHandler);
 
        InteractionInputCollection? prepareArguments = null;
        var service = CreateResourceWithAllocatedEndpoint(builder, "service");
        service.WithHttpCommandExport(
            "/send",
            "Send Message",
            new HttpCommandExportOptions
            {
                MethodName = "POST",
                CommandName = "mycommand",
                PrepareRequest = context =>
                {
                    prepareArguments = context.Arguments;
                    var message = context.Arguments.GetString("message")!;
 
                    return Task.FromResult(new HttpCommandRequestExportData
                    {
                        MethodName = "PUT",
                        Content = message,
                        ContentType = "text/plain",
                        Headers = new Dictionary<string, string>
                        {
                            ["X-Message"] = message
                        }
                    });
                },
                CommandOptions = new CommandOptions
                {
                    Arguments =
                    [
                        new InteractionInput
                        {
                            Name = "message",
                            InputType = InputType.Text,
                            Required = true
                        }
                    ]
                }
            });
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        await MoveResourceToRunningStateAsync(app, service.Resource);
 
        var arguments = new InteractionInputCollection(
        [
            new InteractionInput
            {
                Name = "message",
                InputType = InputType.Text,
                Value = "hello-from-ts-prepare-request"
            }
        ]);
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(service.Resource, "mycommand", arguments).DefaultTimeout();
 
        Assert.True(result.Success);
        Assert.Equal("hello-from-ts-prepare-request", prepareArguments?.GetString("message"));
        Assert.Equal(HttpMethod.Put, fakeHandler.RequestMethod);
        Assert.Equal("hello-from-ts-prepare-request", fakeHandler.RequestContent);
        Assert.Equal("hello-from-ts-prepare-request", fakeHandler.RequestHeader);
        Assert.Equal("text/plain", fakeHandler.RequestContentType);
    }
 
    [Fact]
    public void WithHttpCommand_AddsResourceCommandAnnotations_WithUniqueCommandNames()
    {
        // Arrange
        using var builder = CreateTestDistributedApplicationBuilder();
        var resourceBuilder = builder.AddContainer("name", "image")
            .WithHttpEndpoint()
            .WithHttpEndpoint(name: "custom-endpoint")
            .WithHttpCommand("/some-path", "Do The Thing")
            .WithHttpCommand("/some-path", "Do The Thing", endpointName: "custom-endpoint")
            .WithHttpCommand("/some-path", "Do The Get Thing", commandOptions: new() { Method = HttpMethod.Get })
            .WithHttpCommand("/some-path", "Do The Get Thing", endpointName: "custom-endpoint", commandOptions: new() { Method = HttpMethod.Get })
            .WithHttpCommand("/some-other-path", "Do The Other Thing")
            // Call it again but just change display name, it should override the previous one with the same path
            .WithHttpCommand("/some-other-path", "Do The Other Thing CHANGED");
 
        // Act
        var commands = resourceBuilder.Resource.Annotations.OfType<ResourceCommandAnnotation>().ToList();
        var command1 = commands.FirstOrDefault(c => c.DisplayName == "Do The Thing");
        var command2 = commands.FirstOrDefault(c => c.DisplayName == "Do The Thing" && c.Name.Contains("custom-endpoint"));
        var command3 = commands.FirstOrDefault(c => c.DisplayName == "Do The Get Thing");
        var command4 = commands.FirstOrDefault(c => c.DisplayName == "Do The Get Thing" && c.Name.Contains("custom-endpoint"));
        var command5 = commands.FirstOrDefault(c => c.DisplayName == "Do The Other Thing");
        var command6 = commands.FirstOrDefault(c => c.DisplayName == "Do The Other Thing CHANGED");
 
        // Assert
        Assert.True(commands.Count >= 5);
        Assert.NotNull(command1);
        Assert.NotNull(command2);
        Assert.NotNull(command3);
        Assert.NotNull(command4);
        Assert.Null(command5); // This one is overridden by the last one
        Assert.NotNull(command6);
    }
 
    [InlineData(200, true)]
    [InlineData(201, true)]
    [InlineData(400, false)]
    [InlineData(401, false)]
    [InlineData(403, false)]
    [InlineData(404, false)]
    [InlineData(500, false)]
    [Theory]
    public async Task WithHttpCommand_ResultsInExpectedResultForStatusCode(int statusCode, bool expectSuccess)
    {
        using var builder = CreateTestDistributedApplicationBuilder();
 
        var fakeHandler = new FakeHttpMessageHandler((HttpStatusCode)statusCode);
        builder.Services.AddHttpClient("commandclient")
            .ConfigurePrimaryHttpMessageHandler(() => fakeHandler);
 
        var service = CreateResourceWithAllocatedEndpoint(builder, "service");
        service.WithHttpCommand($"/status/{statusCode}", "Do The Thing", commandName: "mycommand", commandOptions: new() { HttpClientName = "commandclient" });
 
        // Act
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        await MoveResourceToRunningStateAsync(app, service.Resource);
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(service.Resource, "mycommand").DefaultTimeout();
 
        // Assert
        Assert.True(fakeHandler.Called, "Expected the HTTP handler to be called");
        Assert.Equal(expectSuccess, result.Success);
    }
 
    [Fact]
    public async Task WithHttpCommand_WhenHttpSendObservesCanceledToken_ReturnsCanceled()
    {
        using var builder = CreateTestDistributedApplicationBuilder();
        using var cts = new CancellationTokenSource();
 
        var fakeHandler = new CancelingHttpMessageHandler(cts);
        builder.Services.AddHttpClient("commandclient")
            .ConfigurePrimaryHttpMessageHandler(() => fakeHandler);
 
        var service = CreateResourceWithAllocatedEndpoint(builder, "service");
        service.WithHttpCommand("/cancel", "Cancel The Thing", commandName: "mycommand", commandOptions: new() { HttpClientName = "commandclient" });
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        await MoveResourceToRunningStateAsync(app, service.Resource);
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(service.Resource, "mycommand", cts.Token).DefaultTimeout();
 
        Assert.True(fakeHandler.Called, "Expected the HTTP handler to be called");
        Assert.False(result.Success);
        Assert.True(result.Canceled);
    }
 
    [InlineData(null, false)] // Default method is POST
    [InlineData("get", true)]
    [InlineData("post", false)]
    [Theory]
    [ActiveIssue("https://github.com/microsoft/aspire/issues/9725")]
    public async Task WithHttpCommand_ResultsInExpectedResultForHttpMethod(string? httpMethod, bool expectSuccess)
    {
        // Arrange
        var method = httpMethod is not null ? new HttpMethod(httpMethod) : HttpCommandOptions.Default.Method;
        using var builder = CreateTestDistributedApplicationBuilder();
 
        // Return 405 Method Not Allowed for POST, 200 OK for GET
        var fakeHandler = new FakeHttpMessageHandler(expectSuccess ? HttpStatusCode.OK : HttpStatusCode.MethodNotAllowed);
        builder.Services.AddHttpClient("commandclient")
            .ConfigurePrimaryHttpMessageHandler(() => fakeHandler);
 
        var service = CreateResourceWithAllocatedEndpoint(builder, "service");
        service.WithHttpCommand("/get-only", "Do The Thing", commandName: "mycommand", commandOptions: new() { Method = method, HttpClientName = "commandclient" });
 
        // Act
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        await MoveResourceToRunningStateAsync(app, service.Resource);
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(service.Resource, "mycommand").DefaultTimeout();
 
        // Assert
        Assert.True(fakeHandler.Called, "Expected the HTTP handler to be called");
        Assert.Equal(expectSuccess, result.Success);
    }
 
    [Fact]
    public async Task WithHttpCommand_UsesNamedHttpClient()
    {
        // Arrange
        using var builder = CreateTestDistributedApplicationBuilder();
        var fakeHandler = new FakeHttpMessageHandler(HttpStatusCode.OK);
        var configuredTimeout = TimeSpan.FromMinutes(5);
        builder.Services.AddHttpClient("commandclient", client => client.Timeout = configuredTimeout)
            .ConfigurePrimaryHttpMessageHandler(() => fakeHandler);
 
        TimeSpan? timeout = null;
        var service = CreateResourceWithAllocatedEndpoint(builder, "service");
        service.WithHttpCommand("/get-only", "Do The Thing", commandName: "mycommand", commandOptions: new()
        {
            HttpClientName = "commandclient",
            PrepareRequest = context =>
            {
                timeout = context.HttpClient.Timeout;
                return Task.CompletedTask;
            }
        });
 
        // Act
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        await MoveResourceToRunningStateAsync(app, service.Resource);
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(service.Resource, "mycommand").DefaultTimeout();
 
        // Assert
        Assert.True(fakeHandler.Called);
        Assert.Equal(configuredTimeout, timeout);
    }
 
    private sealed class FakeHttpMessageHandler(HttpStatusCode statusCode, string? responseBody = null, string? mediaType = null) : HttpMessageHandler
    {
        public bool Called { get; private set; }
        public HttpMethod? RequestMethod { get; private set; }
        public string? RequestContent { get; private set; }
        public string? RequestContentType { get; private set; }
        public string? RequestHeader { get; private set; }
 
        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            Called = true;
            RequestMethod = request.Method;
            RequestContent = request.Content is not null
                ? await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false)
                : null;
            RequestContentType = request.Content?.Headers.ContentType?.MediaType;
            RequestHeader = request.Headers.TryGetValues("X-Message", out var values)
                ? Assert.Single(values)
                : null;
 
            var response = new HttpResponseMessage(statusCode);
            if (responseBody is not null)
            {
                response.Content = mediaType is not null
                    ? new StringContent(responseBody, Encoding.UTF8, mediaType)
                    : new StringContent(responseBody);
            }
 
            return response;
        }
    }
 
    private sealed class CancelingHttpMessageHandler(CancellationTokenSource cancellationTokenSource) : HttpMessageHandler
    {
        public bool Called { get; private set; }
 
        protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            Called = true;
            cancellationTokenSource.Cancel();
            throw new OperationCanceledException(cancellationToken);
        }
    }
 
    [Fact]
    public async Task WithHttpCommand_UsesEndpointSelector()
    {
        // Arrange
        using var builder = CreateTestDistributedApplicationBuilder();
 
        var fakeHandler = new FakeHttpMessageHandler(HttpStatusCode.OK);
        builder.Services.AddHttpClient("commandclient")
            .ConfigurePrimaryHttpMessageHandler(() => fakeHandler);
 
        var serviceA = CreateResourceWithAllocatedEndpoint(builder, "servicea");
        var callbackCalled = false;
        var serviceB = builder.AddResource(new CustomResource("serviceb"))
            .WithHttpEndpoint(targetPort: 8081)
            .WithHttpCommand("/status/200", "Do The Thing", commandName: "mycommand",
                endpointSelector: () =>
                {
                    callbackCalled = true;
                    return serviceA.GetEndpoint("http");
                },
                commandOptions: new() { HttpClientName = "commandclient" });
 
        // Act
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        // Move serviceA to running (which the command's endpoint selector points to)
        await app.ResourceNotifications.PublishUpdateAsync(serviceA.Resource, s => s with
        {
            State = KnownResourceStates.Running
        }).DefaultTimeout();
        await app.ResourceNotifications.WaitForResourceAsync(serviceA.Resource.Name, KnownResourceStates.Running).DefaultTimeout();
 
        // Move serviceB to running and wait for command to become enabled
        await MoveResourceToRunningStateAsync(app, serviceB.Resource);
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(serviceB.Resource, "mycommand").DefaultTimeout();
 
        // Assert
        Assert.True(callbackCalled);
        Assert.True(fakeHandler.Called);
    }
 
    [Fact]
    public async Task WithHttpCommand_CallsPrepareRequestCallback_BeforeSendingRequest()
    {
        // Arrange
        var callbackCalled = false;
        using var builder = CreateTestDistributedApplicationBuilder();
 
        var fakeHandler = new FakeHttpMessageHandler(HttpStatusCode.OK);
        builder.Services.AddHttpClient("commandclient")
            .ConfigurePrimaryHttpMessageHandler(() => fakeHandler);
 
        var service = CreateResourceWithAllocatedEndpoint(builder, "service");
        service.WithHttpCommand("/status/200", "Do The Thing",
            commandName: "mycommand",
            commandOptions: new()
            {
                HttpClientName = "commandclient",
                PrepareRequest = requestContext =>
                {
                    Assert.NotNull(requestContext);
                    Assert.NotNull(requestContext.Services);
                    Assert.Equal(service.Resource.Name, requestContext.ResourceName);
                    Assert.NotNull(requestContext.Endpoint);
                    Assert.NotNull(requestContext.HttpClient);
                    Assert.NotNull(requestContext.Request);
 
                    callbackCalled = true;
                    return Task.CompletedTask;
                }
            });
 
        // Act
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        await MoveResourceToRunningStateAsync(app, service.Resource);
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(service.Resource, "mycommand").DefaultTimeout();
 
        // Assert
        Assert.True(callbackCalled);
        Assert.True(result.Success);
    }
 
    [Fact]
    public async Task WithHttpCommand_CallsGetResponseCallback_AfterSendingRequest()
    {
        // Arrange
        var callbackCalled = false;
        using var builder = CreateTestDistributedApplicationBuilder();
 
        var fakeHandler = new FakeHttpMessageHandler(HttpStatusCode.OK);
        builder.Services.AddHttpClient("commandclient")
            .ConfigurePrimaryHttpMessageHandler(() => fakeHandler);
 
        var service = CreateResourceWithAllocatedEndpoint(builder, "service");
        service.WithHttpCommand("/status/200", "Do The Thing",
            commandName: "mycommand",
            commandOptions: new()
            {
                HttpClientName = "commandclient",
                GetCommandResult = resultContext =>
                {
                    Assert.NotNull(resultContext);
                    Assert.NotNull(resultContext.Services);
                    Assert.Equal(service.Resource.Name, resultContext.ResourceName);
                    Assert.NotNull(resultContext.Endpoint);
                    Assert.NotNull(resultContext.HttpClient);
                    Assert.NotNull(resultContext.Response);
 
                    callbackCalled = true;
                    return Task.FromResult(CommandResults.Failure("A test error message"));
                }
            });
 
        // Act
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        await MoveResourceToRunningStateAsync(app, service.Resource);
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(service.Resource, "mycommand").DefaultTimeout();
 
        // Assert
        Assert.True(callbackCalled);
        Assert.False(result.Success);
        Assert.Equal("A test error message", result.Message);
    }
 
    [Fact]
    public async Task WithHttpCommand_PassesArgumentsToRequestAndResultCallbacks()
    {
        using var builder = CreateTestDistributedApplicationBuilder();
 
        var fakeHandler = new FakeHttpMessageHandler(HttpStatusCode.OK, "ok", "text/plain");
        builder.Services.AddHttpClient("commandclient")
            .ConfigurePrimaryHttpMessageHandler(() => fakeHandler);
 
        InteractionInputCollection? prepareArguments = null;
        InteractionInputCollection? resultArguments = null;
        var service = CreateResourceWithAllocatedEndpoint(builder, "service");
        service.WithHttpCommand("/send", "Send Message",
            commandName: "mycommand",
            commandOptions: new()
            {
                HttpClientName = "commandclient",
                Arguments =
                [
                    new InteractionInput
                    {
                        Name = "message",
                        InputType = InputType.Text,
                        Required = true
                    }
                ],
                PrepareRequest = requestContext =>
                {
                    prepareArguments = requestContext.Arguments;
                    var message = requestContext.Arguments.GetString("message")!;
                    requestContext.Request.Headers.Add("X-Message", message);
                    requestContext.Request.Content = new StringContent(message, Encoding.UTF8, "text/plain");
                    return Task.CompletedTask;
                },
                GetCommandResult = resultContext =>
                {
                    resultArguments = resultContext.Arguments;
                    return Task.FromResult(CommandResults.Success(resultContext.Arguments.GetString("message")!));
                }
            });
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        await MoveResourceToRunningStateAsync(app, service.Resource);
 
        var arguments = new InteractionInputCollection(
        [
            new InteractionInput
            {
                Name = "message",
                InputType = InputType.Text,
                Value = "hello-from-argument"
            }
        ]);
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(service.Resource, "mycommand", arguments).DefaultTimeout();
 
        Assert.True(result.Success);
        Assert.Equal("hello-from-argument", result.Message);
        Assert.Equal("hello-from-argument", prepareArguments?.GetString("message"));
        Assert.Equal("hello-from-argument", resultArguments?.GetString("message"));
        Assert.Equal("hello-from-argument", fakeHandler.RequestContent);
        Assert.Equal("hello-from-argument", fakeHandler.RequestHeader);
    }
 
    [Fact]
    public async Task WithHttpCommand_WithoutOptIn_DoesNotReturnJsonResponseBody()
    {
        // Arrange
        using var builder = CreateTestDistributedApplicationBuilder();
 
        var fakeHandler = new FakeHttpMessageHandler(HttpStatusCode.OK, """{"token":"abc123"}""", "application/json");
        builder.Services.AddHttpClient("commandclient")
            .ConfigurePrimaryHttpMessageHandler(() => fakeHandler);
 
        var service = CreateResourceWithAllocatedEndpoint(builder, "service");
        service.WithHttpCommand("/token", "Generate Token", commandName: "mycommand", commandOptions: new() { HttpClientName = "commandclient" });
 
        // Act
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        await MoveResourceToRunningStateAsync(app, service.Resource);
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(service.Resource, "mycommand").DefaultTimeout();
 
        // Assert
        Assert.True(result.Success);
        Assert.Null(result.Data);
    }
 
    [Fact]
    public async Task WithHttpCommand_WithResultModeJson_ReturnsJsonResponseBody()
    {
        // Arrange
        using var builder = CreateTestDistributedApplicationBuilder();
 
        var fakeHandler = new FakeHttpMessageHandler(HttpStatusCode.OK, """{"token":"abc123"}""", "application/json");
        builder.Services.AddHttpClient("commandclient")
            .ConfigurePrimaryHttpMessageHandler(() => fakeHandler);
 
        var service = CreateResourceWithAllocatedEndpoint(builder, "service");
        service.WithHttpCommand("/token", "Generate Token", commandName: "mycommand", commandOptions: new() { HttpClientName = "commandclient", ResultMode = HttpCommandResultMode.Json });
 
        // Act
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        await MoveResourceToRunningStateAsync(app, service.Resource);
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(service.Resource, "mycommand").DefaultTimeout();
 
        // Assert
        Assert.True(result.Success);
        Assert.Equal("""{"token":"abc123"}""", result.Data?.Value);
        Assert.Equal(CommandResultFormat.Json, result.Data?.Format);
    }
 
    [Fact]
    public async Task WithHttpCommand_WithResultModeText_ReturnsTextResponseBody()
    {
        // Arrange
        using var builder = CreateTestDistributedApplicationBuilder();
 
        var fakeHandler = new FakeHttpMessageHandler(HttpStatusCode.BadRequest, "invalid request", "text/plain");
        builder.Services.AddHttpClient("commandclient")
            .ConfigurePrimaryHttpMessageHandler(() => fakeHandler);
 
        var service = CreateResourceWithAllocatedEndpoint(builder, "service");
        service.WithHttpCommand("/text", "Get Text", commandName: "mycommand", commandOptions: new() { HttpClientName = "commandclient", ResultMode = HttpCommandResultMode.Text });
 
        // Act
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        await MoveResourceToRunningStateAsync(app, service.Resource);
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(service.Resource, "mycommand").DefaultTimeout();
 
        // Assert
        Assert.False(result.Success);
        Assert.Equal("Request failed with status code BadRequest", result.Message);
        Assert.Equal("invalid request", result.Data?.Value);
        Assert.Equal(CommandResultFormat.Text, result.Data?.Format);
    }
 
    [Fact]
    public async Task WithHttpCommand_WithResultModeAuto_ReturnsJsonResponseBody()
    {
        // Arrange
        using var builder = CreateTestDistributedApplicationBuilder();
 
        var fakeHandler = new FakeHttpMessageHandler(HttpStatusCode.OK, """{"token":"abc123"}""", "application/json");
        builder.Services.AddHttpClient("commandclient")
            .ConfigurePrimaryHttpMessageHandler(() => fakeHandler);
 
        var service = CreateResourceWithAllocatedEndpoint(builder, "service");
        service.WithHttpCommand("/token", "Generate Token", commandName: "mycommand", commandOptions: new() { HttpClientName = "commandclient", ResultMode = HttpCommandResultMode.Auto });
 
        // Act
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        await MoveResourceToRunningStateAsync(app, service.Resource);
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(service.Resource, "mycommand").DefaultTimeout();
 
        // Assert
        Assert.True(result.Success);
        Assert.Equal("""{"token":"abc123"}""", result.Data?.Value);
        Assert.Equal(CommandResultFormat.Json, result.Data?.Format);
    }
 
    [Fact]
    public async Task WithHttpCommand_WithResultModeAuto_ReturnsTextResponseBody()
    {
        // Arrange
        using var builder = CreateTestDistributedApplicationBuilder();
 
        var fakeHandler = new FakeHttpMessageHandler(HttpStatusCode.BadRequest, "invalid request", "text/plain");
        builder.Services.AddHttpClient("commandclient")
            .ConfigurePrimaryHttpMessageHandler(() => fakeHandler);
 
        var service = CreateResourceWithAllocatedEndpoint(builder, "service");
        service.WithHttpCommand("/text", "Get Text", commandName: "mycommand", commandOptions: new() { HttpClientName = "commandclient", ResultMode = HttpCommandResultMode.Auto });
 
        // Act
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        await MoveResourceToRunningStateAsync(app, service.Resource);
 
        var result = await app.ResourceCommands.ExecuteCommandAsync(service.Resource, "mycommand").DefaultTimeout();
 
        // Assert
        Assert.False(result.Success);
        Assert.Equal("Request failed with status code BadRequest", result.Message);
        Assert.Equal("invalid request", result.Data?.Value);
        Assert.Equal(CommandResultFormat.Text, result.Data?.Format);
    }
 
    [Fact]
    public async Task WithHttpCommand_EnablesCommandOnceResourceIsRunning()
    {
        // Arrange
        using var builder = CreateTestDistributedApplicationBuilder();
 
        builder.Configuration["CODESPACES"] = "false";
 
        var service = builder.AddResource(new CustomResource("service"))
            .WithHttpEndpoint()
            .WithHttpCommand("/dothing", "Do The Thing", commandName: "mycommand");
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        // Move the resource to the starting state and verify command is disabled
        await app.ResourceNotifications.PublishUpdateAsync(service.Resource, s => s with
        {
            State = KnownResourceStates.Starting
        }).DefaultTimeout();
 
        var startingEvent = await app.ResourceNotifications.WaitForResourceAsync(
            service.Resource.Name,
            e => e.Snapshot.State?.Text == KnownResourceStates.Starting).DefaultTimeout();
 
        Assert.Equal(ResourceCommandState.Disabled, startingEvent.Snapshot.Commands.First(c => c.Name == "mycommand").State);
 
        // Move the resource to the running state
        await app.ResourceNotifications.PublishUpdateAsync(service.Resource, s => s with
        {
            State = KnownResourceStates.Running
        }).DefaultTimeout();
 
        // Wait for the command to become enabled (this happens after resource becomes ready)
        var runningEvent = await app.ResourceNotifications.WaitForResourceAsync(
            service.Resource.Name,
            e => e.Snapshot.State?.Text == KnownResourceStates.Running &&
                 e.Snapshot.Commands.First(c => c.Name == "mycommand").State == ResourceCommandState.Enabled).DefaultTimeout();
 
        Assert.Equal(ResourceCommandState.Enabled, runningEvent.Snapshot.Commands.First(c => c.Name == "mycommand").State);
 
        await app.StopAsync().DefaultTimeout();
    }
 
    [Fact]
    public async Task WithHttpCommand_EnablesCommandUsingCustomUpdateStateCallback()
    {
        // Arrange
        using var builder = CreateTestDistributedApplicationBuilder();
 
        builder.Configuration["CODESPACES"] = "false";
 
        var enableCommand = false;
        var callbackCalled = false;
        var service = builder.AddResource(new CustomResource("service"))
            .WithHttpEndpoint()
            .WithHttpCommand("/dothing", "Do The Thing",
                commandName: "mycommand",
                commandOptions: new()
                {
                    UpdateState = usc =>
                    {
                        callbackCalled = true;
                        return enableCommand ? ResourceCommandState.Enabled : ResourceCommandState.Hidden;
                    }
                });
 
        using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
 
        // Move the resource to the running state
        await app.ResourceNotifications.PublishUpdateAsync(service.Resource, s => s with
        {
            State = KnownResourceStates.Running
        }).DefaultTimeout();
 
        // Wait for the resource to be running and verify command is hidden (custom UpdateState returns Hidden)
        var runningEvent = await app.ResourceNotifications.WaitForResourceAsync(
            service.Resource.Name,
            e => e.Snapshot.State?.Text == KnownResourceStates.Running &&
                 e.Snapshot.Commands.First(c => c.Name == "mycommand").State == ResourceCommandState.Hidden).DefaultTimeout();
 
        Assert.Equal(ResourceCommandState.Hidden, runningEvent.Snapshot.Commands.First(c => c.Name == "mycommand").State);
 
        // Enable the command and publish an update to force reevaluation
        enableCommand = true;
        await app.ResourceNotifications.PublishUpdateAsync(service.Resource, s => s with
        {
            State = KnownResourceStates.Running
        }).DefaultTimeout();
 
        // Wait for the command to become enabled
        var enabledEvent = await app.ResourceNotifications.WaitForResourceAsync(
            service.Resource.Name,
            e => e.Snapshot.Commands.First(c => c.Name == "mycommand").State == ResourceCommandState.Enabled).DefaultTimeout();
 
        Assert.True(callbackCalled);
        Assert.Equal(ResourceCommandState.Enabled, enabledEvent.Snapshot.Commands.First(c => c.Name == "mycommand").State);
 
        await app.StopAsync().DefaultTimeout();
    }
 
    private IDistributedApplicationTestingBuilder CreateTestDistributedApplicationBuilder()
    {
        var builder = TestDistributedApplicationBuilder.Create(testOutputHelper);
 
        // Disable retries for the commandclient HTTP client to make test faster and deterministic.
        // TestDistributedApplicationBuilder adds a default resilience handler via ConfigureHttpClientDefaults.
        // The handler pipeline is named "{clientName}-standard" so for "commandclient" it's "commandclient-standard".
        builder.Services.Configure<HttpStandardResilienceOptions>("commandclient-standard", options =>
        {
            options.Retry.ShouldHandle = _ => ValueTask.FromResult(false);
        });
        builder.Services.Configure<HttpStandardResilienceOptions>("-standard", options =>
        {
            options.Retry.ShouldHandle = _ => ValueTask.FromResult(false);
        });
        return builder;
    }
 
    /// <summary>
    /// Moves a resource to the running state and waits for the HTTP command to become enabled.
    /// </summary>
    private static async Task MoveResourceToRunningStateAsync(DistributedApplication app, IResource resource, string commandName = "mycommand")
    {
        await app.ResourceNotifications.PublishUpdateAsync(resource, s => s with
        {
            State = KnownResourceStates.Running
        }).DefaultTimeout();
 
        // Wait for resource to be running AND for the command to become enabled
        // The command state is updated synchronously when PublishUpdateAsync is called, but we still need to wait
        // for the notification to propagate through the ResourceNotificationService event system
        await app.ResourceNotifications.WaitForResourceAsync(
            resource.Name,
            e => e.Snapshot.State?.Text == KnownResourceStates.Running &&
                 e.Snapshot.Commands.FirstOrDefault(c => c.Name == commandName)?.State == ResourceCommandState.Enabled).DefaultTimeout();
    }
 
    /// <summary>
    /// Creates a CustomResource with an HTTP endpoint and pre-allocates the endpoint
    /// so HTTP commands can resolve the URL without requiring DCP allocation.
    /// </summary>
    private static IResourceBuilder<CustomResource> CreateResourceWithAllocatedEndpoint(IDistributedApplicationBuilder builder, string name, int port = 8080)
    {
        var service = builder.AddResource(new CustomResource(name))
            .WithHttpEndpoint(targetPort: port);
 
        var endpointAnnotation = service.Resource.Annotations.OfType<EndpointAnnotation>().Single();
        endpointAnnotation.AllocatedEndpoint = new AllocatedEndpoint(endpointAnnotation, "localhost", port);
 
        return service;
    }
 
    private sealed class CustomResource(string name) : Resource(name), IResourceWithEndpoints, IResourceWithWaitSupport
    {
 
    }
}