// 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
}