DesignCommandTests.cs•28 kB
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.CommandLine;
using System.CommandLine.Parsing;
using System.Reflection;
using System.Text;
using System.Text.Json;
using AzureMcp.CloudArchitect;
using AzureMcp.CloudArchitect.Commands.Design;
using AzureMcp.CloudArchitect.Options;
using AzureMcp.Core.Models;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace AzureMcp.CloudArchitect.UnitTests.Design;
public class DesignCommandTests
{
    private readonly IServiceProvider _serviceProvider;
    private readonly ILogger<DesignCommand> _logger;
    private readonly DesignCommand _command;
    private readonly CommandContext _context;
    private readonly Parser _parser;
    public DesignCommandTests()
    {
        _logger = Substitute.For<ILogger<DesignCommand>>();
        var collection = new ServiceCollection();
        _serviceProvider = collection.BuildServiceProvider();
        _command = new(_logger);
        _context = new(_serviceProvider);
        _parser = new(_command.GetCommand());
    }
    [Fact]
    public void Constructor_InitializesCommandCorrectly()
    {
        var command = _command.GetCommand();
        Assert.Equal("design", command.Name);
        Assert.NotNull(command.Description);
        Assert.NotEmpty(command.Description);
        // Check that the description contains the expected content
        Assert.Contains("Azure architecture design tool that gathers requirements through guided questions and recommends optimal solutions.", command.Description);
        Assert.Contains("Key parameters: question, questionNumber, confidenceScore (0.0-1.0, present architecture when ≥0.7), totalQuestions, answer, nextQuestionNeeded, architectureComponent, architectureTier, state.", command.Description);
        Assert.Contains("Ask about user role, business goals (1-2 questions at a time)", command.Description);
        Assert.Contains("Track confidence and update requirements (explicit/implicit/assumed)", command.Description);
        Assert.Contains("When confident enough, present architecture with table format, visual organization, ASCII diagrams", command.Description);
        Assert.Contains("Follow Azure Well-Architected Framework principles", command.Description);
        Assert.Contains("Cover all tiers: infrastructure, platform, application, data, security, operations", command.Description);
        Assert.Contains("State tracks components, requirements by category, and confidence factors. Be conservative with suggestions.", command.Description);
        Assert.Contains("confidenceScore", command.Description);
        Assert.Contains("nextQuestionNeeded", command.Description);
        Assert.Contains("Azure Well-Architected Framework", command.Description);
    }
    [Fact]
    public void Command_HasCorrectOptions()
    {
        var command = _command.GetCommand();
        // Check that the command has the expected options
        var optionNames = command.Options.Select(o => o.Name).ToList();
        Assert.Contains("question", optionNames);
        Assert.Contains("question-number", optionNames);
        Assert.Contains("total-questions", optionNames);
        Assert.Contains("answer", optionNames);
        Assert.Contains("next-question-needed", optionNames);
        Assert.Contains("confidence-score", optionNames);
        Assert.Contains("state", optionNames);
    }
    [Theory]
    [InlineData("")]
    [InlineData("--question \"What is your application type?\"")]
    [InlineData("--question-number 1")]
    [InlineData("--total-questions 5")]
    [InlineData("--answer \"Web application\"")]
    [InlineData("--next-question-needed true")]
    [InlineData("--confidence-score 0.8")]
    [InlineData("--architecture-component \"Frontend\"")]
    [InlineData("--architecture-tier Infrastructure")]
    [InlineData("--question \"App type?\" --question-number 1 --total-questions 5")]
    [InlineData("--architecture-tier Platform --architecture-component \"AKS Cluster\"")]
    public async Task ExecuteAsync_ReturnsArchitectureDesignText(string args)
    {
        // Arrange
        var parseResult = _parser.Parse(args.Split(' ', StringSplitOptions.RemoveEmptyEntries));
        // Act
        var response = await _command.ExecuteAsync(_context, parseResult);
        // Assert
        Assert.Equal(200, response.Status);
        Assert.NotNull(response.Results);
        Assert.Empty(response.Message);
        // Verify that results contain the architecture design response structure
        using var stream = new MemoryStream();
        using var writer = new Utf8JsonWriter(stream);
        response.Results.Write(writer);
        writer.Flush();
        var serializedResult = Encoding.UTF8.GetString(stream.ToArray());
        var responseObject = JsonSerializer.Deserialize(serializedResult, CloudArchitectJsonContext.Default.CloudArchitectDesignResponse);
        Assert.NotNull(responseObject);
        Assert.NotEmpty(responseObject.DesignArchitecture);
        Assert.NotNull(responseObject.ResponseObject);
        // Verify it contains some expected architecture-related content
        Assert.Contains("architecture", responseObject.DesignArchitecture.ToLower());
    }
    [Fact]
    public async Task ExecuteAsync_ConsistentResults()
    {
        // Arrange
        var parseResult1 = _parser.Parse(["--question", "test question 1"]);
        var parseResult2 = _parser.Parse(["--question", "test question 2"]);
        // Act
        var response1 = await _command.ExecuteAsync(_context, parseResult1);
        var response2 = await _command.ExecuteAsync(_context, parseResult2);
        // Assert - Both calls should return the same architecture design text
        Assert.Equal(200, response1.Status);
        Assert.Equal(200, response2.Status);
        // Serialize both results to compare the design architecture text (which should be consistent)
        string serializedResult1 = SerializeResponseResult(response1.Results!);
        string serializedResult2 = SerializeResponseResult(response2.Results!);
        var responseObject1 = JsonSerializer.Deserialize(serializedResult1, CloudArchitectJsonContext.Default.CloudArchitectDesignResponse);
        var responseObject2 = JsonSerializer.Deserialize(serializedResult2, CloudArchitectJsonContext.Default.CloudArchitectDesignResponse);
        Assert.NotNull(responseObject1);
        Assert.NotNull(responseObject2);
        // The design architecture text should be consistent across calls
        Assert.Equal(responseObject1.DesignArchitecture, responseObject2.DesignArchitecture);
        Assert.NotEmpty(responseObject1.DesignArchitecture);
    }
    [Fact]
    public async Task ExecuteAsync_WithAllOptionsSet()
    {
        // Arrange
        var args = new[]
        {
            "--question", "What is your application type?",
            "--question-number", "1",
            "--total-questions", "5",
            "--answer", "Web application",
            "--next-question-needed", "true",
            "--confidence-score", "0.8",
        };
        var parseResult = _parser.Parse(args);
        // Act
        var response = await _command.ExecuteAsync(_context, parseResult);
        // Assert
        Assert.Equal(200, response.Status);
        Assert.NotNull(response.Results);
        Assert.Empty(response.Message);
        // Verify the command executed successfully regardless of the input options
        string serializedResult = SerializeResponseResult(response.Results);
        var responseObject = JsonSerializer.Deserialize(serializedResult, CloudArchitectJsonContext.Default.CloudArchitectDesignResponse);
        Assert.NotNull(responseObject);
        Assert.NotEmpty(responseObject.DesignArchitecture);
        Assert.NotNull(responseObject.ResponseObject);
        Assert.Equal("What is your application type?", responseObject.ResponseObject.DisplayText);
    }
    [Theory]
    [InlineData("What's your app type?", "What's your app type?")]
    [InlineData("How \"big\" is your app?", "How \"big\" is your app?")]
    [InlineData("Is it a \"web app\" or \"mobile app\"?", "Is it a \"web app\" or \"mobile app\"?")]
    [InlineData("What's the app's \"main purpose\"?", "What's the app's \"main purpose\"?")]
    [InlineData("Use 'single quotes' here", "Use 'single quotes' here")]
    [InlineData("Mixed \"quotes\" and 'apostrophes'", "Mixed \"quotes\" and 'apostrophes'")]
    public async Task ExecuteAsync_HandlesQuotesAndEscapingProperly(string questionWithQuotes, string expectedQuestion)
    {
        // Arrange
        var args = new[] { "--question", questionWithQuotes };
        var parseResult = _parser.Parse(args);
        // Act
        var response = await _command.ExecuteAsync(_context, parseResult);
        // Assert
        Assert.Equal(200, response.Status);
        Assert.NotNull(response.Results);
        Assert.Empty(response.Message);
        // Verify that the command executed successfully with the quoted input
        string serializedResult = SerializeResponseResult(response.Results);
        var responseObject = JsonSerializer.Deserialize(serializedResult, CloudArchitectJsonContext.Default.CloudArchitectDesignResponse);
        Assert.NotNull(responseObject);
        Assert.NotEmpty(responseObject.DesignArchitecture);
        Assert.NotNull(responseObject.ResponseObject);
        // Verify the question was parsed correctly by checking the DisplayText in response
        Assert.Equal(expectedQuestion, responseObject.ResponseObject.DisplayText);
    }
    [Fact]
    public async Task ExecuteAsync_HandlesComplexEscapingScenarios()
    {
        // Arrange - Test multiple options with various escaping scenarios
        var complexQuestion = "What is your \"primary\" application 'type' and how \"big\" will it be?";
        var complexAnswer = "It's a \"web application\" with 'high' scalability requirements";
        var args = new[]
        {
            "--question", complexQuestion,
            "--answer", complexAnswer,
            "--question-number", "2",
            "--total-questions", "10"
        };
        var parseResult = _parser.Parse(args);
        // Act
        var response = await _command.ExecuteAsync(_context, parseResult);
        // Assert
        Assert.Equal(200, response.Status);
        Assert.NotNull(response.Results);
        Assert.Empty(response.Message);
        // Verify all options were parsed correctly
        var questionValue = parseResult.GetValueForOption(_command.GetCommand().Options.First(o => o.Name == "question"));
        var answerValue = parseResult.GetValueForOption(_command.GetCommand().Options.First(o => o.Name == "answer"));
        Assert.Equal(complexQuestion, questionValue);
        Assert.Equal(complexAnswer, answerValue);
    }
    [Fact]
    public void Metadata_IsConfiguredCorrectly()
    {
        // Arrange & Act
        var metadata = _command.Metadata;
        // Assert
        Assert.False(metadata.Destructive);
        Assert.True(metadata.ReadOnly);
    }
    [Fact]
    public void Properties_AreConfiguredCorrectly()
    {
        // Arrange & Act
        var name = _command.Name;
        var title = _command.Title;
        var description = _command.Description;
        // Assert
        Assert.Equal("design", name);
        Assert.Equal("Design Azure cloud architectures through guided questions", title);
        Assert.NotEmpty(description);
        Assert.Contains("guided questions", description);
    }
    [Fact]
    public async Task ExecuteAsync_LoadsEmbeddedResourceText()
    {
        // Arrange
        var args = new[] { "--question", "Test question" };
        var parseResult = _parser.Parse(args);
        // Act
        var response = await _command.ExecuteAsync(_context, parseResult);
        // Assert
        Assert.Equal(200, response.Status);
        string serializedResult = SerializeResponseResult(response.Results!);
        var responseObject = JsonSerializer.Deserialize(serializedResult, CloudArchitectJsonContext.Default.CloudArchitectDesignResponse);
        Assert.NotNull(responseObject);
        Assert.NotEmpty(responseObject.DesignArchitecture);
        // The embedded resource should contain Azure architecture guidance
        Assert.Contains("Azure", responseObject.DesignArchitecture);
    }
    [Fact]
    public async Task ExecuteAsync_WithStateOption()
    {
        // Arrange - Create a simple JSON state object
        var stateJson = "{\"architectureComponents\":[],\"architectureTiers\":{\"infrastructure\":[],\"platform\":[],\"application\":[],\"data\":[],\"security\":[],\"operations\":[]},\"requirements\":{\"explicit\":[],\"implicit\":[],\"assumed\":[]},\"confidenceFactors\":{\"explicitRequirementsCoverage\":0.5,\"implicitRequirementsCertainty\":0.7,\"assumptionRisk\":0.3}}";
        var args = new[] { "--state", stateJson };
        var parseResult = _parser.Parse(args);
        // Act
        var response = await _command.ExecuteAsync(_context, parseResult);
        // Assert
        Assert.Equal(200, response.Status);
        Assert.NotNull(response.Results);
        Assert.Empty(response.Message);
        // Verify the command executed successfully with state option
        string serializedResult = SerializeResponseResult(response.Results);
        var responseObject = JsonSerializer.Deserialize(serializedResult, CloudArchitectJsonContext.Default.CloudArchitectDesignResponse);
        Assert.NotNull(responseObject);
        Assert.NotEmpty(responseObject.DesignArchitecture);
        Assert.NotNull(responseObject.ResponseObject);
        Assert.NotNull(responseObject.ResponseObject.State);
    }
    [Fact]
    public async Task ExecuteAsync_WithCompleteOptionSet()
    {
        // Arrange - Test all options together including the new ones
        var args = new[]
        {
            "--question", "What type of application are you building?",
            "--question-number", "3",
            "--total-questions", "8",
            "--answer", "A financial trading platform",
            "--next-question-needed", "false",
            "--confidence-score", "0.9",
        };
        var parseResult = _parser.Parse(args);
        // Act
        var response = await _command.ExecuteAsync(_context, parseResult);
        // Assert
        Assert.Equal(200, response.Status);
        Assert.NotNull(response.Results);
        Assert.Empty(response.Message);
        // Verify all options were parsed correctly
        var command = _command.GetCommand();
        var questionValue = parseResult.GetValueForOption(command.Options.First(o => o.Name == "question"));
        var questionNumberValue = parseResult.GetValueForOption(command.Options.First(o => o.Name == "question-number"));
        var totalQuestionsValue = parseResult.GetValueForOption(command.Options.First(o => o.Name == "total-questions"));
        var answerValue = parseResult.GetValueForOption(command.Options.First(o => o.Name == "answer"));
        var nextQuestionNeededValue = parseResult.GetValueForOption(command.Options.First(o => o.Name == "next-question-needed"));
        var confidenceScoreValue = parseResult.GetValueForOption(command.Options.First(o => o.Name == "confidence-score"));
        Assert.Equal("What type of application are you building?", questionValue);
        Assert.Equal(3, questionNumberValue);
        Assert.Equal(8, totalQuestionsValue);
        Assert.Equal("A financial trading platform", answerValue);
        Assert.Equal(false, nextQuestionNeededValue);
        Assert.Equal(0.9, confidenceScoreValue);
        // Verify the response structure
        string serializedResult = SerializeResponseResult(response.Results);
        var responseObject = JsonSerializer.Deserialize(serializedResult, CloudArchitectJsonContext.Default.CloudArchitectDesignResponse);
        Assert.NotNull(responseObject);
        Assert.NotEmpty(responseObject.DesignArchitecture);
        Assert.NotNull(responseObject.ResponseObject);
        Assert.Equal(questionValue, responseObject.ResponseObject.DisplayText);
        Assert.Equal(questionNumberValue, responseObject.ResponseObject.QuestionNumber);
        Assert.Equal(totalQuestionsValue, responseObject.ResponseObject.TotalQuestions);
        Assert.Equal(nextQuestionNeededValue, responseObject.ResponseObject.NextQuestionNeeded);
    }
    private static string SerializeResponseResult(ResponseResult responseResult)
    {
        using var stream = new MemoryStream();
        using var writer = new Utf8JsonWriter(stream);
        responseResult.Write(writer);
        writer.Flush();
        return Encoding.UTF8.GetString(stream.ToArray());
    }
    #region Validation Tests
    [Theory]
    [InlineData(-0.1)]
    [InlineData(1.1)]
    [InlineData(2.0)]
    [InlineData(-1.0)]
    public void Parse_InvalidConfidenceScore_ReturnsError(double invalidScore)
    {
        // Arrange
        var args = new[] { "--confidence-score", invalidScore.ToString() };
        // Act
        var parseResult = _parser.Parse(args);
        // Assert
        Assert.NotEmpty(parseResult.Errors);
        Assert.Contains("Confidence score must be between 0.0 and 1.0", parseResult.Errors.Select(e => e.Message));
    }
    [Theory]
    [InlineData(0.0)]
    [InlineData(0.5)]
    [InlineData(1.0)]
    [InlineData(0.1)]
    [InlineData(0.9)]
    public void Parse_ValidConfidenceScore_NoErrors(double validScore)
    {
        // Arrange
        var args = new[] { "--confidence-score", validScore.ToString() };
        // Act
        var parseResult = _parser.Parse(args);
        // Assert
        Assert.Empty(parseResult.Errors);
    }
    [Theory]
    [InlineData(-1)]
    [InlineData(-5)]
    [InlineData(-100)]
    public void Parse_NegativeQuestionNumber_ReturnsError(int invalidQuestionNumber)
    {
        // Arrange
        var args = new[] { "--question-number", invalidQuestionNumber.ToString() };
        // Act
        var parseResult = _parser.Parse(args);
        // Assert
        Assert.NotEmpty(parseResult.Errors);
        Assert.Contains("Question number cannot be negative", parseResult.Errors.Select(e => e.Message));
    }
    [Theory]
    [InlineData(0)]
    [InlineData(1)]
    [InlineData(5)]
    [InlineData(100)]
    public void Parse_ValidQuestionNumber_NoErrors(int validQuestionNumber)
    {
        // Arrange
        var args = new[] { "--question-number", validQuestionNumber.ToString() };
        // Act
        var parseResult = _parser.Parse(args);
        // Assert
        Assert.Empty(parseResult.Errors);
    }
    [Theory]
    [InlineData(-1)]
    [InlineData(-5)]
    [InlineData(-100)]
    public void Parse_NegativeTotalQuestions_ReturnsError(int invalidTotalQuestions)
    {
        // Arrange
        var args = new[] { "--total-questions", invalidTotalQuestions.ToString() };
        // Act
        var parseResult = _parser.Parse(args);
        // Assert
        Assert.NotEmpty(parseResult.Errors);
        Assert.Contains("Total questions cannot be negative", parseResult.Errors.Select(e => e.Message));
    }
    [Theory]
    [InlineData(0)]
    [InlineData(1)]
    [InlineData(5)]
    [InlineData(100)]
    public void Parse_ValidTotalQuestions_NoErrors(int validTotalQuestions)
    {
        // Arrange
        var args = new[] { "--total-questions", validTotalQuestions.ToString() };
        // Act
        var parseResult = _parser.Parse(args);
        // Assert
        Assert.Empty(parseResult.Errors);
    }
    [Theory]
    [InlineData(1, 5)]
    [InlineData(5, 5)]
    [InlineData(3, 10)]
    [InlineData(0, 5)] // Zero is valid for question number
    public void Parse_QuestionNumberWithinTotalQuestions_NoErrors(int questionNumber, int totalQuestions)
    {
        // Arrange
        var args = new[]
        {
            "--question-number", questionNumber.ToString(),
            "--total-questions", totalQuestions.ToString()
        };
        // Act
        var parseResult = _parser.Parse(args);
        // Assert
        Assert.Empty(parseResult.Errors);
    }
    [Fact]
    public void Parse_MultipleValidationErrors_ReturnsFirstError()
    {
        // Arrange - Set both invalid confidence score and negative question number
        var args = new[]
        {
            "--confidence-score", "1.5",
            "--question-number", "-1"
        };
        // Act
        var parseResult = _parser.Parse(args);
        // Assert
        Assert.NotEmpty(parseResult.Errors);
        // Should return the first validation error encountered
        Assert.Contains("Confidence score must be between 0.0 and 1.0", parseResult.Errors.Select(e => e.Message));
    }
    [Fact]
    public async Task ExecuteAsync_WithComplexStateJson_ParsesSuccessfully()
    {
        // Arrange - Use the exact JSON from the original error
        var stateJson = """
                        {
                            "architectureComponents": [],
                            "architectureTiers": {
                                "infrastructure": [],
                                "platform": [],
                                "application": [],
                                "data": [],
                                "security": [],
                                "operations": []
                            },
                            "requirements": {
                                "explicit": [
                                    {
                                        "category": "functionality",
                                        "description": "Video upload capability for users",
                                        "source": "User statement",
                                        "importance": "high",
                                        "confidence": 1
                                    },
                                    {
                                        "category": "functionality",
                                        "description": "Video viewing/playback capability for users",
                                        "source": "User statement",
                                        "importance": "high",
                                        "confidence": 1
                                    }
                                ],
                                "implicit": [
                                    {
                                        "category": "performance",
                                        "description": "Large-scale video processing and streaming required",
                                        "source": "Inferred from 'large video playback company'",
                                        "importance": "high",
                                        "confidence": 0.9
                                    }
                                ],
                                "assumed": [
                                    {
                                        "category": "scale",
                                        "description": "Potentially thousands of concurrent users",
                                        "source": "Assumed from 'large' company description",
                                        "importance": "medium",
                                        "confidence": 0.6
                                    }
                                ]
                            },
                            "confidenceFactors": {
                                "explicitRequirementsCoverage": 0.4,
                                "implicitRequirementsCertainty": 0.8,
                                "assumptionRisk": 0.4
                            }
                        }
                        """;
        var args = new[]
        {
            "--state", stateJson,
            "--question", "What is your primary business goal?",
            "--confidence-score", "0.5"
        };
        // Act
        var parseResult = _parser.Parse(args);
        var result = await _command.ExecuteAsync(_context, parseResult);
        // Assert
        Assert.Empty(parseResult.Errors);
        Assert.Equal(200, _context.Response.Status);
        // Verify that the state was parsed correctly by checking the response
        string serializedResult = SerializeResponseResult(_context.Response.Results!);
        var responseObject = JsonSerializer.Deserialize(serializedResult, CloudArchitectJsonContext.Default.CloudArchitectDesignResponse);
        Assert.NotNull(responseObject);
        Assert.NotNull(responseObject.ResponseObject.State);
        Assert.Empty(responseObject.ResponseObject.State.ArchitectureComponents);
        Assert.NotNull(responseObject.ResponseObject.State.Requirements);
        Assert.Equal(2, responseObject.ResponseObject.State.Requirements.Explicit.Count);
        Assert.Single(responseObject.ResponseObject.State.Requirements.Implicit);
        Assert.Single(responseObject.ResponseObject.State.Requirements.Assumed);
    }
    [Fact]
    public async Task ExecuteAsync_WithInvalidStateJson_HandlesGracefully()
    {
        // Arrange
        var invalidStateJson = "{ invalid json }";
        var args = new[] { "--state", invalidStateJson };
        var parseResult = _parser.Parse(args);
        // Act
        var response = await _command.ExecuteAsync(_context, parseResult);
        // Assert - The command should handle the error gracefully and return an error response
        Assert.NotEqual(200, response.Status);
        Assert.NotEmpty(response.Message);
    }
    [Fact]
    public async Task ExecuteAsync_WithEmptyState_CreatesDefaultState()
    {
        // Arrange
        var args = new[] { "--state", "" };
        var parseResult = _parser.Parse(args);
        // Act
        var result = await _command.ExecuteAsync(_context, parseResult);
        // Assert
        Assert.Equal(200, _context.Response.Status);
        string serializedResult = SerializeResponseResult(_context.Response.Results!);
        var responseObject = JsonSerializer.Deserialize(serializedResult, CloudArchitectJsonContext.Default.CloudArchitectDesignResponse);
        Assert.NotNull(responseObject);
        Assert.NotNull(responseObject.ResponseObject.State);
        Assert.Empty(responseObject.ResponseObject.State.ArchitectureComponents);
        Assert.NotNull(responseObject.ResponseObject.State.Requirements);
        Assert.Empty(responseObject.ResponseObject.State.Requirements.Explicit);
    }
    [Fact]
    public void BindOptions_WithInvalidStateJson_ThrowsException()
    {
        // Arrange
        var invalidStateJson = "{ invalid json }";
        var args = new[] { "--state", invalidStateJson };
        var parseResult = _parser.Parse(args);
        // Act & Assert
        var exception = Assert.Throws<TargetInvocationException>(() =>
        {
            // Access the protected BindOptions method via reflection to test state deserialization
            var command = _command.GetCommand();
            var stateOption = command.Options.First(o => o.Name == "state");
            var stateValue = parseResult.GetValueForOption((Option<string>)stateOption);
            // Manually call the state deserialization that happens in BindOptions
            var deserializeMethod = typeof(DesignCommand).GetMethod("DeserializeState",
                System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
            Assert.NotNull(deserializeMethod);
            deserializeMethod.Invoke(null, new object?[] { stateValue });
        });
        // Verify the inner exception is the InvalidOperationException we expect
        Assert.IsType<InvalidOperationException>(exception.InnerException);
        Assert.Contains("Failed to deserialize state JSON", exception.InnerException!.Message);
    }
    #endregion
}