diff --git a/servers/Azure.Mcp.Server/changelog-entries/1773129554713.yaml b/servers/Azure.Mcp.Server/changelog-entries/1773129554713.yaml new file mode 100644 index 0000000000..4d723f36f4 --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/1773129554713.yaml @@ -0,0 +1,3 @@ +changes: + - section: "Features Added" + description: "Added `azmcp containerapps list` command to list Azure Container Apps in a subscription" diff --git a/servers/Azure.Mcp.Server/docs/azmcp-commands.md b/servers/Azure.Mcp.Server/docs/azmcp-commands.md index 82c7f6a270..31202631f7 100644 --- a/servers/Azure.Mcp.Server/docs/azmcp-commands.md +++ b/servers/Azure.Mcp.Server/docs/azmcp-commands.md @@ -1258,6 +1258,19 @@ azmcp confidentialledger entries get --ledger \ - `--collection-id`: Collection ID to store the data with (optional) - `--transaction-id`: Ledger transaction identifier to retrieve (required for the get command) +### Azure Container Apps Operations + +```bash +# List Azure Container Apps in a subscription +# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp containerapps list --subscription + +# List Azure Container Apps in a specific resource group +# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired +azmcp containerapps list --subscription \ + [--resource-group ] +``` + ### Azure Container Registry (ACR) Operations ```bash diff --git a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md index 3c49ac7bbc..5dadede44d 100644 --- a/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md +++ b/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md @@ -139,6 +139,15 @@ This file contains prompts used for end-to-end testing to ensure each tool is in | extension_cli_install | How to install azd | | extension_cli_install | What is Azure Functions Core tools and how to install it | +## Azure Container Apps + +| Tool Name | Test Prompt | +|:----------|:----------| +| containerapps_list | List all Azure Container Apps in my subscription | +| containerapps_list | Show me my Azure Container Apps | +| containerapps_list | List container apps in resource group | +| containerapps_list | Show me the container apps in resource group | + ## Azure Container Registry (ACR) | Tool Name | Test Prompt | diff --git a/servers/Azure.Mcp.Server/src/Program.cs b/servers/Azure.Mcp.Server/src/Program.cs index d6d36ea515..1cab870900 100644 --- a/servers/Azure.Mcp.Server/src/Program.cs +++ b/servers/Azure.Mcp.Server/src/Program.cs @@ -111,6 +111,7 @@ private static IAreaSetup[] RegisterAreas() new Azure.Mcp.Tools.CloudArchitect.CloudArchitectSetup(), new Azure.Mcp.Tools.Communication.CommunicationSetup(), new Azure.Mcp.Tools.Compute.ComputeSetup(), + new Azure.Mcp.Tools.ContainerApps.ContainerAppsSetup(), new Azure.Mcp.Tools.ConfidentialLedger.ConfidentialLedgerSetup(), new Azure.Mcp.Tools.EventHubs.EventHubsSetup(), new Azure.Mcp.Tools.FileShares.FileSharesSetup(), diff --git a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json index 8f3221b933..5f7400b994 100644 --- a/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json +++ b/servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json @@ -2227,7 +2227,7 @@ }, { "name": "get_azure_container_details", - "description": "Get details about Azure container services including Azure Container Registry (ACR) and Azure Kubernetes Service (AKS). View registries, repositories, nodepools, clusters, cluster configurations, and individual nodepool details.", + "description": "Get details about Azure container services including Azure Container Registry (ACR), Azure Kubernetes Service (AKS), and Azure Container Apps. View registries, repositories, nodepools, clusters, cluster configurations, individual nodepool details, and container apps.", "toolMetadata": { "destructive": { "value": false, @@ -2258,7 +2258,8 @@ "acr_registry_list", "acr_registry_repository_list", "aks_cluster_get", - "aks_nodepool_get" + "aks_nodepool_get", + "containerapps_list" ] }, { diff --git a/tools/Azure.Mcp.Tools.ContainerApps/src/AssemblyInfo.cs b/tools/Azure.Mcp.Tools.ContainerApps/src/AssemblyInfo.cs new file mode 100644 index 0000000000..fb713746da --- /dev/null +++ b/tools/Azure.Mcp.Tools.ContainerApps/src/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Azure.Mcp.Tools.ContainerApps.UnitTests")] +[assembly: InternalsVisibleTo("Azure.Mcp.Tools.ContainerApps.LiveTests")] diff --git a/tools/Azure.Mcp.Tools.ContainerApps/src/Azure.Mcp.Tools.ContainerApps.csproj b/tools/Azure.Mcp.Tools.ContainerApps/src/Azure.Mcp.Tools.ContainerApps.csproj new file mode 100644 index 0000000000..7154740818 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ContainerApps/src/Azure.Mcp.Tools.ContainerApps.csproj @@ -0,0 +1,19 @@ + + + true + + + + + + + + + + + + + + + + diff --git a/tools/Azure.Mcp.Tools.ContainerApps/src/Commands/BaseContainerAppsCommand.cs b/tools/Azure.Mcp.Tools.ContainerApps/src/Commands/BaseContainerAppsCommand.cs new file mode 100644 index 0000000000..046b16cea6 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ContainerApps/src/Commands/BaseContainerAppsCommand.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using Azure.Mcp.Core.Commands.Subscription; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models.Option; +using Microsoft.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Tools.ContainerApps.Commands; + +public abstract class BaseContainerAppsCommand< + [DynamicallyAccessedMembers(TrimAnnotations.CommandAnnotations)] TOptions> + : SubscriptionCommand where TOptions : SubscriptionOptions, new() +{ + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(OptionDefinitions.Common.ResourceGroup.AsOptional()); + } + + protected override TOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.ResourceGroup ??= parseResult.GetValueOrDefault(OptionDefinitions.Common.ResourceGroup.Name); + return options; + } +} diff --git a/tools/Azure.Mcp.Tools.ContainerApps/src/Commands/ContainerApp/ContainerAppListCommand.cs b/tools/Azure.Mcp.Tools.ContainerApps/src/Commands/ContainerApp/ContainerAppListCommand.cs new file mode 100644 index 0000000000..340b2bd547 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ContainerApps/src/Commands/ContainerApp/ContainerAppListCommand.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tools.ContainerApps.Options.ContainerApp; +using Azure.Mcp.Tools.ContainerApps.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Models.Command; + +namespace Azure.Mcp.Tools.ContainerApps.Commands.ContainerApp; + +public sealed class ContainerAppListCommand(ILogger logger, IContainerAppsService containerAppsService) : BaseContainerAppsCommand +{ + private const string CommandTitle = "List Container Apps"; + private readonly ILogger _logger = logger; + private readonly IContainerAppsService _containerAppsService = containerAppsService; + + public override string Id => "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f90"; + + public override string Name => "list"; + + public override string Description => + $""" + List Azure Container Apps in a subscription. Optionally filter by resource group. Each container app result + includes: name, location, resourceGroup, managedEnvironmentId, provisioningState. If no container apps are + found the tool returns an empty list of results (consistent with other list commands). + """; + + public override string Title => CommandTitle; + + public override ToolMetadata Metadata => new() + { + Destructive = false, + Idempotent = true, + OpenWorld = false, + ReadOnly = true, + LocalRequired = false, + Secret = false + }; + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + + try + { + var containerApps = await _containerAppsService.ListContainerApps( + options.Subscription!, + options.ResourceGroup, + options.Tenant, + options.RetryPolicy, + cancellationToken); + + context.Response.Results = ResponseResult.Create(new(containerApps?.Results ?? [], containerApps?.AreResultsTruncated ?? false), ContainerAppsJsonContext.Default.ContainerAppListCommandResult); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Error listing container apps. Subscription: {Subscription}, ResourceGroup: {ResourceGroup}, Options: {@Options}", + options.Subscription, options.ResourceGroup, options); + HandleException(context, ex); + } + + return context.Response; + } + + internal record ContainerAppListCommandResult(List ContainerApps, bool AreResultsTruncated); +} diff --git a/tools/Azure.Mcp.Tools.ContainerApps/src/Commands/ContainerAppsJsonContext.cs b/tools/Azure.Mcp.Tools.ContainerApps/src/Commands/ContainerAppsJsonContext.cs new file mode 100644 index 0000000000..3dfe7035dc --- /dev/null +++ b/tools/Azure.Mcp.Tools.ContainerApps/src/Commands/ContainerAppsJsonContext.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Azure.Mcp.Tools.ContainerApps.Commands.ContainerApp; + +namespace Azure.Mcp.Tools.ContainerApps.Commands; + +[JsonSerializable(typeof(ContainerAppListCommand.ContainerAppListCommandResult))] +[JsonSerializable(typeof(Models.ContainerAppInfo))] +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +internal sealed partial class ContainerAppsJsonContext : JsonSerializerContext +{ +} diff --git a/tools/Azure.Mcp.Tools.ContainerApps/src/ContainerAppsSetup.cs b/tools/Azure.Mcp.Tools.ContainerApps/src/ContainerAppsSetup.cs new file mode 100644 index 0000000000..07f1a06508 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ContainerApps/src/ContainerAppsSetup.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Tools.ContainerApps.Commands.ContainerApp; +using Azure.Mcp.Tools.ContainerApps.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Mcp.Core.Areas; +using Microsoft.Mcp.Core.Commands; + +namespace Azure.Mcp.Tools.ContainerApps; + +public class ContainerAppsSetup : IAreaSetup +{ + public string Name => "containerapps"; + + public string Title => "Azure Container Apps Management"; + + public void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(); + + services.AddSingleton(); + } + + public CommandGroup RegisterCommands(IServiceProvider serviceProvider) + { + var containerapps = new CommandGroup(Name, "Azure Container Apps operations - Commands for managing Azure Container Apps resources. Includes operations for listing container apps and managing container app configurations.", Title); + + var containerAppList = serviceProvider.GetRequiredService(); + containerapps.AddCommand(containerAppList.Name, containerAppList); + + return containerapps; + } +} diff --git a/tools/Azure.Mcp.Tools.ContainerApps/src/GlobalUsings.cs b/tools/Azure.Mcp.Tools.ContainerApps/src/GlobalUsings.cs new file mode 100644 index 0000000000..cb8de3ef35 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ContainerApps/src/GlobalUsings.cs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +global using System.CommandLine; +global using Azure.Mcp.Core.Commands; +global using Azure.Mcp.Core.Options; diff --git a/tools/Azure.Mcp.Tools.ContainerApps/src/Models/ContainerAppInfo.cs b/tools/Azure.Mcp.Tools.ContainerApps/src/Models/ContainerAppInfo.cs new file mode 100644 index 0000000000..4c9898447e --- /dev/null +++ b/tools/Azure.Mcp.Tools.ContainerApps/src/Models/ContainerAppInfo.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Tools.ContainerApps.Models; + +public sealed record ContainerAppInfo( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("location")] string? Location, + [property: JsonPropertyName("resourceGroup")] string? ResourceGroup, + [property: JsonPropertyName("managedEnvironmentId")] string? ManagedEnvironmentId, + [property: JsonPropertyName("provisioningState")] string? ProvisioningState); diff --git a/tools/Azure.Mcp.Tools.ContainerApps/src/Options/ContainerApp/ContainerAppListOptions.cs b/tools/Azure.Mcp.Tools.ContainerApps/src/Options/ContainerApp/ContainerAppListOptions.cs new file mode 100644 index 0000000000..1cd09e0ef3 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ContainerApps/src/Options/ContainerApp/ContainerAppListOptions.cs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Tools.ContainerApps.Options.ContainerApp; + +public class ContainerAppListOptions : SubscriptionOptions +{ +} diff --git a/tools/Azure.Mcp.Tools.ContainerApps/src/Services/ContainerAppsService.cs b/tools/Azure.Mcp.Tools.ContainerApps/src/Services/ContainerAppsService.cs new file mode 100644 index 0000000000..c47d32ac8f --- /dev/null +++ b/tools/Azure.Mcp.Tools.ContainerApps/src/Services/ContainerAppsService.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using Azure.Mcp.Core.Services.Azure; +using Azure.Mcp.Core.Services.Azure.Subscription; +using Azure.Mcp.Core.Services.Azure.Tenant; +using Azure.Mcp.Tools.ContainerApps.Models; + +namespace Azure.Mcp.Tools.ContainerApps.Services; + +public sealed class ContainerAppsService(ISubscriptionService subscriptionService, ITenantService tenantService) + : BaseAzureResourceService(subscriptionService, tenantService), IContainerAppsService +{ + public async Task> ListContainerApps( + string subscription, + string? resourceGroup = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default) + { + ValidateRequiredParameters((nameof(subscription), subscription)); + + var containerApps = await ExecuteResourceQueryAsync( + "Microsoft.App/containerApps", + resourceGroup, + subscription, + retryPolicy, + ConvertToContainerAppInfoModel, + cancellationToken: cancellationToken); + + return containerApps; + } + + private static ContainerAppInfo ConvertToContainerAppInfoModel(JsonElement item) + { + var name = item.TryGetProperty("name", out var nameElement) ? nameElement.GetString() ?? string.Empty : string.Empty; + var location = item.TryGetProperty("location", out var locationElement) ? locationElement.GetString() : null; + var resourceGroup = item.TryGetProperty("resourceGroup", out var rgElement) ? rgElement.GetString() : null; + + string? managedEnvironmentId = null; + string? provisioningState = null; + + if (item.TryGetProperty("properties", out var properties)) + { + managedEnvironmentId = properties.TryGetProperty("managedEnvironmentId", out var envElement) ? envElement.GetString() : null; + provisioningState = properties.TryGetProperty("provisioningState", out var stateElement) ? stateElement.GetString() : null; + } + + return new ContainerAppInfo(name, location, resourceGroup, managedEnvironmentId, provisioningState); + } +} diff --git a/tools/Azure.Mcp.Tools.ContainerApps/src/Services/IContainerAppsService.cs b/tools/Azure.Mcp.Tools.ContainerApps/src/Services/IContainerAppsService.cs new file mode 100644 index 0000000000..1d90029524 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ContainerApps/src/Services/IContainerAppsService.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Services.Azure; +using Azure.Mcp.Tools.ContainerApps.Models; + +namespace Azure.Mcp.Tools.ContainerApps.Services; + +public interface IContainerAppsService +{ + Task> ListContainerApps( + string subscription, + string? resourceGroup = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null, + CancellationToken cancellationToken = default); +} diff --git a/tools/Azure.Mcp.Tools.ContainerApps/tests/Azure.Mcp.Tools.ContainerApps.UnitTests/AssemblyAttributes.cs b/tools/Azure.Mcp.Tools.ContainerApps/tests/Azure.Mcp.Tools.ContainerApps.UnitTests/AssemblyAttributes.cs new file mode 100644 index 0000000000..69da1d7967 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ContainerApps/tests/Azure.Mcp.Tools.ContainerApps.UnitTests/AssemblyAttributes.cs @@ -0,0 +1,2 @@ +[assembly: Microsoft.Mcp.Tests.Helpers.ClearEnvironmentVariablesBeforeTest] +[assembly: Xunit.CollectionBehavior(Xunit.CollectionBehavior.CollectionPerAssembly)] diff --git a/tools/Azure.Mcp.Tools.ContainerApps/tests/Azure.Mcp.Tools.ContainerApps.UnitTests/Azure.Mcp.Tools.ContainerApps.UnitTests.csproj b/tools/Azure.Mcp.Tools.ContainerApps/tests/Azure.Mcp.Tools.ContainerApps.UnitTests/Azure.Mcp.Tools.ContainerApps.UnitTests.csproj new file mode 100644 index 0000000000..d00e463e54 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ContainerApps/tests/Azure.Mcp.Tools.ContainerApps.UnitTests/Azure.Mcp.Tools.ContainerApps.UnitTests.csproj @@ -0,0 +1,22 @@ + + + enable + enable + false + + + + + + + + + + + + + + + + + diff --git a/tools/Azure.Mcp.Tools.ContainerApps/tests/Azure.Mcp.Tools.ContainerApps.UnitTests/ContainerApp/ContainerAppListCommandTests.cs b/tools/Azure.Mcp.Tools.ContainerApps/tests/Azure.Mcp.Tools.ContainerApps.UnitTests/ContainerApp/ContainerAppListCommandTests.cs new file mode 100644 index 0000000000..b16da54a83 --- /dev/null +++ b/tools/Azure.Mcp.Tools.ContainerApps/tests/Azure.Mcp.Tools.ContainerApps.UnitTests/ContainerApp/ContainerAppListCommandTests.cs @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Helpers; +using Azure.Mcp.Core.Options; +using Azure.Mcp.Core.Services.Azure; +using Azure.Mcp.Tools.ContainerApps.Commands; +using Azure.Mcp.Tools.ContainerApps.Commands.ContainerApp; +using Azure.Mcp.Tools.ContainerApps.Models; +using Azure.Mcp.Tools.ContainerApps.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Command; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Azure.Mcp.Tools.ContainerApps.UnitTests.ContainerApp; + +public class ContainerAppListCommandTests +{ + private readonly IContainerAppsService _service; + private readonly ILogger _logger; + private readonly ContainerAppListCommand _command; + private readonly CommandContext _context; + private readonly Command _commandDefinition; + + public ContainerAppListCommandTests() + { + _service = Substitute.For(); + _logger = Substitute.For>(); + + _command = new(_logger, _service); + _context = new(new ServiceCollection().BuildServiceProvider()); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + var command = _command.GetCommand(); + Assert.Equal("list", command.Name); + Assert.NotNull(command.Description); + Assert.NotEmpty(command.Description); + } + + [Theory] + [InlineData("--subscription sub", true)] + [InlineData("--subscription sub --resource-group rg", true)] + [InlineData("", false)] + public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) + { + var originalSubscriptionId = Environment.GetEnvironmentVariable("AZURE_SUBSCRIPTION_ID"); + try + { + // Ensure environment variable fallback does not interfere with validation tests + EnvironmentHelpers.SetAzureSubscriptionId(null); + // Arrange + if (shouldSucceed) + { + _service.ListContainerApps(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ResourceQueryResults( + [ + new("app1", "eastus", "rg1", "/subscriptions/sub/resourceGroups/rg1/providers/Microsoft.App/managedEnvironments/env1", "Succeeded"), + new("app2", "eastus2", "rg2", "/subscriptions/sub/resourceGroups/rg2/providers/Microsoft.App/managedEnvironments/env2", "Succeeded") + ], false)); + } + + var parseResult = _commandDefinition.Parse(args); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(shouldSucceed ? HttpStatusCode.OK : HttpStatusCode.BadRequest, response.Status); + if (shouldSucceed) + { + Assert.NotNull(response.Results); + } + else + { + Assert.Contains("required", response.Message.ToLower()); + } + } + finally + { + EnvironmentHelpers.SetAzureSubscriptionId(originalSubscriptionId); + } + } + + [Fact] + public async Task ExecuteAsync_HandlesServiceErrors() + { + // Arrange + _service.ListContainerApps(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new Exception("Test error")); + + var parseResult = _commandDefinition.Parse(["--subscription", "sub"]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.InternalServerError, response.Status); + Assert.Contains("Test error", response.Message); + Assert.Contains("troubleshooting", response.Message); + } + + [Fact] + public async Task ExecuteAsync_FiltersByResourceGroup_ReturnsFilteredContainerApps() + { + // Arrange + var expectedApps = new ResourceQueryResults([new("app1", null, null, null, null)], false); + _service.ListContainerApps("sub", "rg", Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(expectedApps); + + var parseResult = _commandDefinition.Parse(["--subscription", "sub", "--resource-group", "rg"]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + await _service.Received(1).ListContainerApps("sub", "rg", Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_EmptyList_ReturnsEmptyResults() + { + // Arrange + _service.ListContainerApps("sub", null, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ResourceQueryResults([], false)); + + var parseResult = _commandDefinition.Parse(["--subscription", "sub"]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var json = JsonSerializer.Serialize(response.Results); + var result = JsonSerializer.Deserialize(json, ContainerAppsJsonContext.Default.ContainerAppListCommandResult); + + Assert.NotNull(result); + Assert.Empty(result.ContainerApps); + } + + [Fact] + public async Task ExecuteAsync_ReturnsExpectedContainerAppProperties() + { + // Arrange + var containerApp = new ContainerAppInfo("myapp", "eastus", "myrg", "/subscriptions/sub/resourceGroups/myrg/providers/Microsoft.App/managedEnvironments/myenv", "Succeeded"); + _service.ListContainerApps("sub", null, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ResourceQueryResults([containerApp], false)); + + var parseResult = _commandDefinition.Parse(["--subscription", "sub"]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + } +}