Skip to content

refactor: extract pure resolver methods for subscription resolution#2395

Closed
jongio wants to merge 2 commits intomicrosoft:mainfrom
jongio:fix/subscription-test-pure-resolvers
Closed

refactor: extract pure resolver methods for subscription resolution#2395
jongio wants to merge 2 commits intomicrosoft:mainfrom
jongio:fix/subscription-test-pure-resolvers

Conversation

@jongio
Copy link
Copy Markdown
Contributor

@jongio jongio commented Apr 13, 2026

Summary

Alternative approach to #2391 for fixing subscription tests when Azure CLI is logged in. Instead of changing SetAzureSubscriptionId to conditionally return CLI profile (which breaks the Set/Get contract), this extracts the resolution logic into pure internal methods with explicit inputs.

Changes

Production code

  • CommandHelper.cs: Added ResolveDefaultSubscription(profileSubscription, envSubscription) and ResolveSubscription(optionValue, defaultSubscription) as pure internal methods. Refactored GetDefaultSubscription/GetSubscription to delegate to them.
  • EnvironmentVariableHelpers.cs: Kept ClearAzureSubscriptionId() (useful addition from Fix subscription ID tests when logged in with Azure CLI #2391 that is not in main yet).

Test code

  • CommandHelperResolverTests.cs (NEW): 13 deterministic tests covering the priority chain with explicit inputs - no env/CLI dependency.
  • CommandHelperTests.cs: Integration tests use GetDefaultSubscription() for expected values so they pass regardless of CLI login state. Added IDisposable for cleanup.
  • SubscriptionCommandTests.cs: Same pattern - uses GetDefaultSubscription() for expected values. Added IDisposable.
  • RegistryListCommandTests.cs: Robust validation test that accounts for CLI profile existence. Uses ClearAzureSubscriptionId() and try/finally for cleanup.
  • ContainerAppListCommandTests.cs: Same pattern as Registry - uses ClearAzureSubscriptionId() and try/finally for cleanup.

Why this approach

  1. Pure resolvers - ResolveDefaultSubscription and ResolveSubscription take explicit inputs, have no global state, and are fully deterministic in tests
  2. No broken contracts - SetAzureSubscriptionId remains a pure setter
  3. No tautological assertions - Pure resolver tests assert against known inputs, not the same code path being tested
  4. No layering inversion - EnvironmentHelpers doesn't depend on CommandHelper

Supersedes #2391

Test results

  • 20 CommandHelper tests pass (7 integration + 13 pure resolver)
  • 3 SubscriptionCommand tests pass
  • 8 ACR Registry tests pass
  • Pre-existing SQL test failures are unrelated (confirmed on base branch)

Invoking Livetests

Copilot submitted PRs are not trustworthy by default. Users with write access to the repo need to validate the contents of this PR before leaving a comment with the text /azp run mcp - pullrequest - live. This will trigger the necessary livetest workflows to complete required validation.

alzimmermsft and others added 2 commits April 13, 2026 10:52
Instead of changing SetAzureSubscriptionId to conditionally return CLI profile
(which breaks the Set/Get contract and creates tautological assertions), this
extracts the resolution logic into pure internal methods with explicit inputs:

- ResolveDefaultSubscription(profileSubscription, envSubscription)
- ResolveSubscription(optionValue, defaultSubscription)

These are fully deterministic and testable without any global state or
environment dependencies. The existing GetDefaultSubscription/GetSubscription
methods are refactored to delegate to these resolvers.

EnvironmentVariableHelpers.SetAzureSubscriptionId is reverted to a pure setter
(void, string?) to restore the clean contract. ClearAzureSubscriptionId is
kept as a useful addition.

Integration tests now use GetDefaultSubscription() for expected values so they
pass regardless of whether Azure CLI is logged in. Pure resolver tests cover
the priority chain deterministically.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors subscription resolution in CommandHelper by extracting deterministic “pure” resolver methods and updates unit tests to be resilient to Azure CLI login state, reducing environment-dependent test failures.

Changes:

  • Added internal pure resolver methods for default-subscription and option-vs-default subscription resolution, and refactored existing APIs to delegate to them.
  • Introduced dedicated resolver unit tests and adjusted existing tests to use resolved defaults (CLI profile > env var) and clean up AZURE_SUBSCRIPTION_ID.
  • Added EnvironmentHelpers.ClearAzureSubscriptionId() and updated tests to use it.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
tools/Azure.Mcp.Tools.ContainerApps/tests/Azure.Mcp.Tools.ContainerApps.UnitTests/ContainerApp/ContainerAppListCommandTests.cs Updates env var handling for validation tests (currently still CLI-state sensitive for empty-args case).
tools/Azure.Mcp.Tools.Acr/tests/Azure.Mcp.Tools.Acr.UnitTests/Registry/RegistryListCommandTests.cs Makes validation test robust to CLI default-subscription presence; restores env var after test.
core/Microsoft.Mcp.Core/src/Helpers/EnvironmentVariableHelpers.cs Adds ClearAzureSubscriptionId() and simplifies helpers with expression-bodied members.
core/Microsoft.Mcp.Core/src/Helpers/CommandHelper.cs Extracts pure resolver methods and refactors GetDefaultSubscription/GetSubscription to use them.
core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Helpers/CommandHelperTests.cs Adjusts expectations to use resolved default subscription and adds cleanup via IDisposable.
core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Helpers/CommandHelperResolverTests.cs New deterministic unit tests covering resolver priority/placeholder behavior.
core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionCommandTests.cs Updates expected subscription to resolved default and adds env cleanup via IDisposable.
Comments suppressed due to low confidence (1)

tools/Azure.Mcp.Tools.ContainerApps/tests/Azure.Mcp.Tools.ContainerApps.UnitTests/ContainerApp/ContainerAppListCommandTests.cs:86

  • This validation test still assumes the empty-args case fails. However, SubscriptionCommand validation succeeds whenever CommandHelper.HasSubscriptionAvailable() is true, which includes having an Azure CLI default subscription (even with AZURE_SUBSCRIPTION_ID cleared). Mirror the RegistryListCommandTests behavior by detecting a non-empty CommandHelper.GetDefaultSubscription() and flipping the expected outcome (or split the test into two cases).
    [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.ClearAzureSubscriptionId();
            // Arrange
            if (shouldSucceed)
            {
                _service.ListContainerApps(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<RetryPolicyOptions>(), Arg.Any<CancellationToken>())
                    .Returns(new ResourceQueryResults<ContainerAppInfo>(
                    [
                        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());
            }


// When Azure CLI is logged in, the empty-args case will resolve a subscription
// from the CLI profile and succeed rather than fail validation.
if (!shouldSucceed && CommandHelper.GetDefaultSubscription() != null)
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The adjustment for Azure CLI login uses CommandHelper.GetDefaultSubscription() != null, but validation uses !string.IsNullOrEmpty(GetDefaultSubscription()). If the CLI/profile resolution ever yields an empty string, this test would incorrectly flip shouldSucceed to true while validation still fails. Use the same non-empty check as CommandHelper.HasSubscriptionAvailable().

Suggested change
if (!shouldSucceed && CommandHelper.GetDefaultSubscription() != null)
if (!shouldSucceed && CommandHelper.HasSubscriptionAvailable())

Copilot uses AI. Check for mistakes.
Comment on lines +42 to +45
public void ResolveDefaultSubscription_ReturnsEnv_WhenBothEmpty()
{
var result = CommandHelper.ResolveDefaultSubscription("", "");
Assert.Equal("", result);
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test ResolveDefaultSubscription_ReturnsEnv_WhenBothEmpty asserts an empty string result when both inputs are empty. That contradicts the production method’s doc (“first non-empty subscription source”) and makes it easier for empty env vars to be treated as a “resolved” value. Consider updating the resolver to treat empty as absent and changing this test to expect null.

Suggested change
public void ResolveDefaultSubscription_ReturnsEnv_WhenBothEmpty()
{
var result = CommandHelper.ResolveDefaultSubscription("", "");
Assert.Equal("", result);
public void ResolveDefaultSubscription_ReturnsNull_WhenBothEmpty()
{
var result = CommandHelper.ResolveDefaultSubscription("", "");
Assert.Null(result);

Copilot uses AI. Check for mistakes.
Comment on lines 17 to +44
[Fact]
public void GetSubscription_EmptySubscriptionParameter_ReturnsEnvironmentValue()
{
// Arrange
EnvironmentHelpers.SetAzureSubscriptionId("env-subs");
var expected = CommandHelper.GetDefaultSubscription();
var parseResult = GetParseResult(["--subscription", ""]);

// Act
var actual = CommandHelper.GetSubscription(parseResult);

// Assert
Assert.Equal("env-subs", actual);
Assert.Equal(expected, actual);
}

[Fact]
public void GetSubscription_MissingSubscriptionParameter_ReturnsEnvironmentValue()
{
// Arrange
EnvironmentHelpers.SetAzureSubscriptionId("env-subs");
var expected = CommandHelper.GetDefaultSubscription();
var parseResult = GetParseResult([]);

// Act
var actual = CommandHelper.GetSubscription(parseResult);

// Assert
Assert.Equal("env-subs", actual);
Assert.Equal(expected, actual);
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Several test names still claim they validate “environment value” behavior, but the assertions now intentionally accept Azure CLI profile precedence by comparing to GetDefaultSubscription(). Consider renaming these tests (or adding clarifying comments) so the name matches what’s actually being asserted.

Copilot uses AI. Check for mistakes.
Comment on lines 46 to 65
@@ -56,6 +61,7 @@ public async Task ExecuteAsync_WithEnvironmentVariableOnly_CallsServiceWithCorre
{
// Arrange
EnvironmentHelpers.SetAzureSubscriptionId("env-subs");
var expectedSubscription = CommandHelper.GetDefaultSubscription();

Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These test names still refer to “environment variable only”, but the expected/verified subscription is now the resolved default (Azure CLI profile can override the env var). Rename the tests to reflect that they validate default-subscription resolution rather than specifically env-var behavior.

Copilot uses AI. Check for mistakes.
/// Priority: Azure CLI profile > AZURE_SUBSCRIPTION_ID environment variable.
/// </summary>
internal static string? ResolveDefaultSubscription(string? profileSubscription, string? envSubscription)
=> !string.IsNullOrEmpty(profileSubscription) ? profileSubscription : envSubscription;
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ResolveDefaultSubscription’s XML doc says it returns the first non-empty source, but the implementation returns envSubscription even when it’s an empty string (e.g., env var set to ""). Consider treating envSubscription as absent when null/empty (and returning null when both sources are null/empty), or adjust the doc/tests to match the intended behavior.

Suggested change
=> !string.IsNullOrEmpty(profileSubscription) ? profileSubscription : envSubscription;
=> !string.IsNullOrEmpty(profileSubscription)
? profileSubscription
: !string.IsNullOrEmpty(envSubscription)
? envSubscription
: null;

Copilot uses AI. Check for mistakes.
Comment on lines +54 to +67
/// <summary>
/// Pure resolution logic: returns the explicit option value if valid, otherwise the default.
/// Placeholder values (containing "subscription" or "default") are treated as absent.
/// </summary>
internal static string? ResolveSubscription(string? optionValue, string? defaultSubscription)
{
// Primary: Azure CLI profile (set via 'az account set') - cached to avoid repeated file I/O
var profileDefault = s_profileDefault.Value;
if (!string.IsNullOrEmpty(profileDefault))
if (!string.IsNullOrEmpty(optionValue) && !IsPlaceholder(optionValue))
{
return profileDefault;
return optionValue;
}

// Fallback: AZURE_SUBSCRIPTION_ID environment variable (cheap, not cached)
return EnvironmentHelpers.GetAzureSubscriptionId();
return !string.IsNullOrEmpty(defaultSubscription)
? defaultSubscription
: optionValue;
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ResolveSubscription’s XML doc says placeholder values are treated as absent, but the method currently returns the placeholder when defaultSubscription is null/empty (to preserve the old behavior). Either update the documentation to reflect this fallback, or change the behavior so placeholders are never returned.

Copilot uses AI. Check for mistakes.
: optionValue;
}

private static bool IsPlaceholder(string value) => value.Contains("subscription") || value.Contains("default");
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IsPlaceholder uses case-sensitive substring matching. This will miss placeholder text like "Azure Subscription 1" or "Default" depending on casing, which can cause unintended validation behavior. Use a StringComparison (e.g., OrdinalIgnoreCase) when checking for placeholder tokens.

Suggested change
private static bool IsPlaceholder(string value) => value.Contains("subscription") || value.Contains("default");
private static bool IsPlaceholder(string value) =>
value.Contains("subscription", StringComparison.OrdinalIgnoreCase) ||
value.Contains("default", StringComparison.OrdinalIgnoreCase);

Copilot uses AI. Check for mistakes.
@jongio
Copy link
Copy Markdown
Contributor Author

jongio commented Apr 13, 2026

Recreating to target the correct base branch (FixLoggedInTests from PR #2391)

@jongio jongio closed this Apr 13, 2026
@github-project-automation github-project-automation bot moved this from Untriaged to Done in Azure MCP Server Apr 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants