diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ccbd39137..7e17fb282 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -111,28 +111,34 @@ images/ # Project images and assets ### Unit Testing Patterns -All new code must include unit tests following the pattern used in `Refitter.Tests.Scenarios` namespace: +All new code must include unit tests following the pattern used in `Refitter.Tests.Scenarios` namespace. + +The project uses **TUnit v1.47.0** as its testing framework (not xUnit). TUnit provides significantly faster test execution (3x faster than xUnit), which improves both local development and CI/CD pipeline performance. + +Use TUnit's `[Test]` attribute for test methods: ```csharp +using TUnit.Core; + public class MyFeatureTests { private const string OpenApiSpec = @"..."; // OpenAPI specification - [Fact] + [Test] public async Task Can_Generate_Code() { string generatedCode = await GenerateCode(); generatedCode.Should().NotBeNullOrWhiteSpace(); } - [Fact] + [Test] public async Task Generated_Code_Contains_Expected_Pattern() { string generatedCode = await GenerateCode(); generatedCode.Should().Contain("ExpectedPattern"); } - [Fact] + [Test] public async Task Can_Build_Generated_Code() { string generatedCode = await GenerateCode(); diff --git a/src/Refitter.Tests/FormParameterExtractorTests.cs b/src/Refitter.Tests/FormParameterExtractorTests.cs new file mode 100644 index 000000000..edae30e53 --- /dev/null +++ b/src/Refitter.Tests/FormParameterExtractorTests.cs @@ -0,0 +1,253 @@ +using System.Runtime.CompilerServices; +using FluentAssertions; +using NJsonSchema; +using NSwag; +using NSwag.CodeGeneration.CSharp.Models; +using Refitter.Core; +using TUnit.Core; + +namespace Refitter.Tests; + +public class FormParameterExtractorTests +{ + [Test] + public void CanExtract_Returns_True_For_FormData() + { + var extractor = new FormParameterExtractor(); + extractor.CanExtract(OpenApiParameterKind.FormData).Should().BeTrue(); + } + + [Test] + public void CanExtract_Returns_False_For_Non_FormData() + { + var extractor = new FormParameterExtractor(); + extractor.CanExtract(OpenApiParameterKind.Query).Should().BeFalse(); + extractor.CanExtract(OpenApiParameterKind.Path).Should().BeFalse(); + extractor.CanExtract(OpenApiParameterKind.Header).Should().BeFalse(); + extractor.CanExtract(OpenApiParameterKind.Body).Should().BeFalse(); + } + + [Test] + public void Extract_Returns_Empty_When_No_FormData_Parameters() + { + var extractor = new FormParameterExtractor(); + var operationModel = CreateEmptyOperationModel(); + var operation = new OpenApiOperation(); + + var result = extractor.Extract(operationModel, operation, new RefitGeneratorSettings()); + result.Should().BeEmpty(); + } + + [Test] + public void Extract_Returns_FormData_Parameters_With_Correct_Format() + { + var extractor = new FormParameterExtractor(); + var parameter = CreateFormDataParameterModel("field1", "field1", "string"); + var operationModel = CreateOperationModel(parameter); + var operation = new OpenApiOperation(); + + var result = extractor.Extract(operationModel, operation, new RefitGeneratorSettings()).ToList(); + result.Should().ContainSingle().Which.Should().Be("string field1"); + } + + [Test] + public void Extract_Handles_Alias_For_Different_Variable_Name() + { + var extractor = new FormParameterExtractor(); + var parameter = CreateFormDataParameterModel("field-name", "field_name", "string"); + var operationModel = CreateOperationModel(parameter); + var operation = new OpenApiOperation(); + + var result = extractor.Extract(operationModel, operation, new RefitGeneratorSettings()).ToList(); + result.Should().ContainSingle().Which.Should().Be("[AliasAs(\"field-name\")] string field_name"); + } + + [Test] + public void Extract_Deduplicates_FormData_Parameters_By_Variable_Name() + { + var extractor = new FormParameterExtractor(); + var parameter1 = CreateFormDataParameterModel("field1", "field1", "string"); + var parameter2 = CreateFormDataParameterModel("field1", "field1", "int"); + var operationModel = CreateOperationModel(parameter1, parameter2); + var operation = new OpenApiOperation(); + + var result = extractor.Extract(operationModel, operation, new RefitGeneratorSettings()).ToList(); + result.Should().ContainSingle(); + } + + [Test] + public void Extract_Extracts_Multipart_Text_Fields_From_RequestBody() + { + var extractor = new FormParameterExtractor(); + var operationModel = CreateEmptyOperationModel(); + var operation = new OpenApiOperation + { + RequestBody = new OpenApiRequestBody() + }; + var schema = new JsonSchema(); + schema.Properties["title"] = new JsonSchemaProperty + { + Type = JsonObjectType.String + }; + schema.Properties["description"] = new JsonSchemaProperty + { + Type = JsonObjectType.String + }; + operation.RequestBody.Content["multipart/form-data"] = new OpenApiMediaType + { + Schema = schema + }; + + var result = extractor.Extract(operationModel, operation, new RefitGeneratorSettings()).ToList(); + + result.Should().HaveCount(2); + result.Should().Contain("string title"); + result.Should().Contain("string description"); + } + + [Test] + public void Extract_Skips_Binary_Fields_From_Multipart_RequestBody() + { + var extractor = new FormParameterExtractor(); + var operationModel = CreateEmptyOperationModel(); + var operation = new OpenApiOperation + { + RequestBody = new OpenApiRequestBody() + }; + var schema = new JsonSchema(); + schema.Properties["avatar"] = new JsonSchemaProperty + { + Type = JsonObjectType.String, + Format = "binary" + }; + schema.Properties["title"] = new JsonSchemaProperty + { + Type = JsonObjectType.String + }; + operation.RequestBody.Content["multipart/form-data"] = new OpenApiMediaType + { + Schema = schema + }; + + var result = extractor.Extract(operationModel, operation, new RefitGeneratorSettings()).ToList(); + + result.Should().ContainSingle().Which.Should().Be("string title"); + } + + [Test] + public void Extract_Skips_Array_Of_Binary_From_Multipart_RequestBody() + { + var extractor = new FormParameterExtractor(); + var operationModel = CreateEmptyOperationModel(); + var operation = new OpenApiOperation + { + RequestBody = new OpenApiRequestBody() + }; + var schema = new JsonSchema(); + schema.Properties["files"] = new JsonSchemaProperty + { + Type = JsonObjectType.Array, + Item = new JsonSchema + { + Type = JsonObjectType.String, + Format = "binary" + } + }; + operation.RequestBody.Content["multipart/form-data"] = new OpenApiMediaType + { + Schema = schema + }; + + var result = extractor.Extract(operationModel, operation, new RefitGeneratorSettings()).ToList(); + result.Should().BeEmpty(); + } + + [Test] + public void Extract_Handles_Missing_RequestBody_Schema() + { + var extractor = new FormParameterExtractor(); + var operationModel = CreateEmptyOperationModel(); + var operation = new OpenApiOperation + { + RequestBody = new OpenApiRequestBody() + }; + operation.RequestBody.Content["multipart/form-data"] = new OpenApiMediaType(); + + var result = extractor.Extract(operationModel, operation, new RefitGeneratorSettings()).ToList(); + result.Should().BeEmpty(); + } + + [Test] + public void Extract_Handles_Null_RequestBody_Properties() + { + var extractor = new FormParameterExtractor(); + var operationModel = CreateEmptyOperationModel(); + var operation = new OpenApiOperation + { + RequestBody = new OpenApiRequestBody() + }; + var schema = new JsonSchema(); + operation.RequestBody.Content["multipart/form-data"] = new OpenApiMediaType + { + Schema = schema + }; + + var result = extractor.Extract(operationModel, operation, new RefitGeneratorSettings()).ToList(); + result.Should().BeEmpty(); + } + + private static CSharpOperationModel CreateEmptyOperationModel() + { + var operationModel = (CSharpOperationModel)RuntimeHelpers.GetUninitializedObject(typeof(CSharpOperationModel)); + var baseType = typeof(CSharpOperationModel).BaseType!; + + baseType + .GetField("k__BackingField", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .SetValue(operationModel, new List()); + + return operationModel; + } + + private static CSharpOperationModel CreateOperationModel(params CSharpParameterModel[] parameters) + { + var operationModel = (CSharpOperationModel)RuntimeHelpers.GetUninitializedObject(typeof(CSharpOperationModel)); + var baseType = typeof(CSharpOperationModel).BaseType!; + + baseType + .GetField("k__BackingField", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .SetValue(operationModel, parameters.ToList()); + + return operationModel; + } + + private static CSharpParameterModel CreateFormDataParameterModel( + string name, + string variableName, + string type = "string") + { + var parameterModel = (CSharpParameterModel)RuntimeHelpers.GetUninitializedObject(typeof(CSharpParameterModel)); + var baseType = typeof(CSharpParameterModel).BaseType!; + + baseType + .GetField("k__BackingField", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .SetValue(parameterModel, type); + baseType + .GetField("k__BackingField", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .SetValue(parameterModel, name); + baseType + .GetField("k__BackingField", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .SetValue(parameterModel, variableName); + baseType + .GetField("_parameter", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .SetValue( + parameterModel, + new OpenApiParameter + { + Name = name, + Kind = OpenApiParameterKind.FormData, + Schema = new JsonSchema() + }); + + return parameterModel; + } +} diff --git a/src/Refitter.Tests/GenerationOrchestratorTests.cs b/src/Refitter.Tests/GenerationOrchestratorTests.cs index 6b680aaa5..5360af95f 100644 --- a/src/Refitter.Tests/GenerationOrchestratorTests.cs +++ b/src/Refitter.Tests/GenerationOrchestratorTests.cs @@ -1,5 +1,8 @@ +using System.Reflection; using FluentAssertions; +using Microsoft.OpenApi; using Refitter.Core; +using Refitter.Validation; using TUnit.Core; namespace Refitter.Tests; @@ -178,4 +181,439 @@ public async Task RunAsync_Should_Return_NonZero_On_Exception() Directory.Delete(workspace, recursive: true); } } + + [Test] + public async Task RunAsync_With_NoBanner_False_Still_Completes_Successfully() + { + var workspace = Path.Combine( + AppContext.BaseDirectory, + "GenerationOrchestratorTests", + Guid.NewGuid().ToString("N")); + + try + { + var openApiPath = Path.Combine(workspace, "spec.json"); + var outputPath = Path.Combine(workspace, "Output.cs"); + Directory.CreateDirectory(workspace); + + File.WriteAllText( + openApiPath, + """ + { + "openapi": "3.0.0", + "info": { "title": "Test API", "version": "1.0.0" }, + "paths": { + "/pets": { + "get": { + "operationId": "GetPets", + "responses": { "200": { "description": "ok" } } + } + } + } + } + """); + + var settings = new RefitGeneratorSettings + { + OpenApiPath = openApiPath, + Namespace = "TestNamespace", + GenerateContracts = false, + GenerateClients = true, + }; + + var cliSettings = new Settings + { + OpenApiPath = openApiPath, + OutputPath = outputPath, + NoLogging = true, + NoBanner = false, + SkipValidation = true, + }; + + var reporter = new SimpleGenerationReporter(); + var orchestrator = new GenerationOrchestrator(); + var result = await orchestrator.RunAsync(settings, cliSettings, reporter, default); + + result.Should().Be(0); + File.Exists(outputPath).Should().BeTrue(); + } + finally + { + if (Directory.Exists(workspace)) + Directory.Delete(workspace, recursive: true); + } + } + + [Test] + public async Task RunAsync_With_MultipleOpenApiPaths_Completes_Successfully() + { + var workspace = Path.Combine( + AppContext.BaseDirectory, + "GenerationOrchestratorTests", + Guid.NewGuid().ToString("N")); + + try + { + var openApiPath1 = Path.Combine(workspace, "spec1.json"); + var openApiPath2 = Path.Combine(workspace, "spec2.json"); + var outputPath = Path.Combine(workspace, "Output.cs"); + Directory.CreateDirectory(workspace); + + var spec = """ + { + "openapi": "3.0.0", + "info": { "title": "Test API", "version": "1.0.0" }, + "paths": { + "/pets": { + "get": { + "operationId": "GetPets", + "responses": { "200": { "description": "ok" } } + } + } + } + } + """; + + File.WriteAllText(openApiPath1, spec); + File.WriteAllText(openApiPath2, spec); + + var settings = new RefitGeneratorSettings + { + OpenApiPaths = [openApiPath1, openApiPath2], + Namespace = "TestNamespace", + GenerateContracts = false, + GenerateClients = true, + }; + + var cliSettings = new Settings + { + OpenApiPath = openApiPath1, + OutputPath = outputPath, + NoLogging = true, + NoBanner = true, + SkipValidation = true, + }; + + var reporter = new SimpleGenerationReporter(); + var orchestrator = new GenerationOrchestrator(); + var result = await orchestrator.RunAsync(settings, cliSettings, reporter, default); + + result.Should().Be(0); + File.Exists(outputPath).Should().BeTrue(); + } + finally + { + if (Directory.Exists(workspace)) + Directory.Delete(workspace, recursive: true); + } + } + + [Test] + public async Task RunAsync_With_UseIsoDateFormat_And_DateFormat_Shows_Warning() + { + var workspace = Path.Combine( + AppContext.BaseDirectory, + "GenerationOrchestratorTests", + Guid.NewGuid().ToString("N")); + + try + { + var openApiPath = Path.Combine(workspace, "spec.json"); + var outputPath = Path.Combine(workspace, "Output.cs"); + Directory.CreateDirectory(workspace); + + File.WriteAllText( + openApiPath, + """ + { + "openapi": "3.0.0", + "info": { "title": "Test API", "version": "1.0.0" }, + "paths": { + "/pets": { + "get": { + "operationId": "GetPets", + "responses": { "200": { "description": "ok" } } + } + } + } + } + """); + + var settings = new RefitGeneratorSettings + { + OpenApiPath = openApiPath, + Namespace = "TestNamespace", + GenerateContracts = false, + GenerateClients = true, + UseIsoDateFormat = true, + CodeGeneratorSettings = new CodeGeneratorSettings + { + DateFormat = "dd/MM/yyyy" + } + }; + + var cliSettings = new Settings + { + OpenApiPath = openApiPath, + OutputPath = outputPath, + NoLogging = true, + NoBanner = true, + SkipValidation = true, + }; + + var reporter = new TestGenerationReporter(); + var orchestrator = new GenerationOrchestrator(); + var result = await orchestrator.RunAsync(settings, cliSettings, reporter, default); + + result.Should().Be(0); + File.Exists(outputPath).Should().BeTrue(); + + reporter.ConfigurationWarnings.Should().ContainSingle(); + reporter.ConfigurationWarnings[0].Title.Should().Be("Date Format Override"); + reporter.ConfigurationWarnings[0].Description.Should().Contain("useIsoDateFormat"); + } + finally + { + if (Directory.Exists(workspace)) + Directory.Delete(workspace, recursive: true); + } + } + + [Test] + public async Task RunAsync_With_Deprecated_UsePolly_Shows_Warning() + { + var workspace = Path.Combine( + AppContext.BaseDirectory, + "GenerationOrchestratorTests", + Guid.NewGuid().ToString("N")); + + try + { + var openApiPath = Path.Combine(workspace, "spec.json"); + var outputPath = Path.Combine(workspace, "Output.cs"); + Directory.CreateDirectory(workspace); + + File.WriteAllText( + openApiPath, + """ + { + "openapi": "3.0.0", + "info": { "title": "Test API", "version": "1.0.0" }, + "paths": { + "/pets": { + "get": { + "operationId": "GetPets", + "responses": { "200": { "description": "ok" } } + } + } + } + } + """); + + var settings = new RefitGeneratorSettings + { + OpenApiPath = openApiPath, + Namespace = "TestNamespace", + GenerateContracts = false, + GenerateClients = true, +#pragma warning disable CS0618 + DependencyInjectionSettings = new DependencyInjectionSettings + { + UsePolly = true + }, +#pragma warning restore CS0618 + }; + + var cliSettings = new Settings + { + OpenApiPath = openApiPath, + OutputPath = outputPath, + NoLogging = true, + NoBanner = true, + SkipValidation = true, + }; + + var reporter = new TestGenerationReporter(); + var orchestrator = new GenerationOrchestrator(); + var result = await orchestrator.RunAsync(settings, cliSettings, reporter, default); + + result.Should().Be(0); + File.Exists(outputPath).Should().BeTrue(); + + reporter.ConfigurationWarnings.Should().ContainSingle(); + reporter.ConfigurationWarnings[0].Title.Should().Be("Deprecated Setting"); + reporter.ConfigurationWarnings[0].Description.Should().Contain("usePolly"); + } + finally + { + if (Directory.Exists(workspace)) + Directory.Delete(workspace, recursive: true); + } + } + + [Test] + public async Task RunAsync_With_IncludePathMatches_NoMatch_Shows_Warning() + { + var workspace = Path.Combine( + AppContext.BaseDirectory, + "GenerationOrchestratorTests", + Guid.NewGuid().ToString("N")); + + try + { + var openApiPath = Path.Combine(workspace, "spec.json"); + var outputPath = Path.Combine(workspace, "Output.cs"); + Directory.CreateDirectory(workspace); + + File.WriteAllText( + openApiPath, + """ + { + "openapi": "3.0.0", + "info": { "title": "Test API", "version": "1.0.0" }, + "paths": { + "/pets": { + "get": { + "operationId": "GetPets", + "responses": { "200": { "description": "ok" } } + } + } + } + } + """); + + var settings = new RefitGeneratorSettings + { + OpenApiPath = openApiPath, + Namespace = "TestNamespace", + GenerateContracts = false, + GenerateClients = true, + IncludePathMatches = ["^/nonexistent$"], + }; + + var cliSettings = new Settings + { + OpenApiPath = openApiPath, + OutputPath = outputPath, + NoLogging = true, + NoBanner = true, + SkipValidation = true, + }; + + var reporter = new TestGenerationReporter(); + var orchestrator = new GenerationOrchestrator(); + var result = await orchestrator.RunAsync(settings, cliSettings, reporter, default); + + result.Should().Be(0); + reporter.AllPathsFilteredWarningCalled.Should().BeTrue(); + reporter.AllPathsFilteredMatchPatterns.Should().ContainSingle() + .Which.Should().Be("^/nonexistent$"); + } + finally + { + if (Directory.Exists(workspace)) + Directory.Delete(workspace, recursive: true); + } + } + + [Test] + public void FormatFileSize_Small_Values() + { + var method = typeof(GenerationOrchestrator).GetMethod( + "FormatFileSize", + BindingFlags.NonPublic | BindingFlags.Static, + null, + [typeof(long)], + null); + + method.Should().NotBeNull(); + + var bytesResult = (string)method!.Invoke(null, [0L])!; + bytesResult.Should().Match("0* B"); + + var kbResult = (string)method.Invoke(null, [1024L])!; + kbResult.Should().Match("1* KB"); + + var mbResult = (string)method.Invoke(null, [1048576L])!; + mbResult.Should().Match("1* MB"); + + var gbResult = (string)method.Invoke(null, [1073741824L])!; + gbResult.Should().Match("1* GB"); + } + + [Test] + public void FormatFileSize_Exact_Boundaries() + { + var method = typeof(GenerationOrchestrator).GetMethod( + "FormatFileSize", + BindingFlags.NonPublic | BindingFlags.Static, + null, + [typeof(long)], + null); + + method.Should().NotBeNull(); + + var justUnder1K = (string)method!.Invoke(null, [1023L])!; + justUnder1K.Should().Match("1023* B"); + + var exactly1K = (string)method.Invoke(null, [1024L])!; + exactly1K.Should().Match("1* KB"); + + var justOver1K = (string)method.Invoke(null, [1025L])!; + justOver1K.Should().Match("1* KB"); + + var justUnder1M = (string)method.Invoke(null, [1048575L])!; + justUnder1M.Should().Match("1024* KB"); + + var exactly1M = (string)method.Invoke(null, [1048576L])!; + exactly1M.Should().Match("1* MB"); + } + + /// + /// Test implementation of IGenerationReporter that captures warnings for verification. + /// + private sealed class TestGenerationReporter : IGenerationReporter + { + public List<(string Title, string Description)> ConfigurationWarnings { get; } = []; + public bool AllPathsFilteredWarningCalled { get; private set; } + public IReadOnlyList AllPathsFilteredMatchPatterns { get; private set; } = []; + + public void ReportHeader(string version) { } + public void ReportSupportKey(string supportKey) { } + public Task ReportSingleFileGenerationProgressAsync() => Task.CompletedTask; + public void ReportSingleFileOutput(string fileName, string directory, string sizeFormatted, int lines) { } + public Task GenerateMultipleFilesWithProgressAsync(Func generate) => Task.FromResult(generate()); + public IMultiFileOutputReport BeginMultiFileOutput() => new TestMultiFileOutputReport(); + public void ReportFileWritten(string outputPath) { } + public async Task ValidateWithProgressAsync(Func> validate) => await validate(); + public void ReportValidationFailed() { } + public void ReportValidationDiagnostic(OpenApiError error, bool isError) { } + public void ReportValidationStatistics(OpenApiValidationResult validationResult) { } + public void ReportSuccess(TimeSpan duration, bool multipleFiles) { } + public void ReportDonationBanner() { } + + public void ReportConfigurationWarnings(IReadOnlyList<(string Title, string Description)> warnings) + { + ConfigurationWarnings.AddRange(warnings); + } + + public void ReportAllPathsFilteredWarning(IReadOnlyList matchPatterns) + { + AllPathsFilteredWarningCalled = true; + AllPathsFilteredMatchPatterns = matchPatterns; + } + + public void ReportSettingsFileGenerated(string settingsFilePath) { } + public void ReportGenerationFailed() { } + public void ReportUnsupportedVersion(string specificationVersion) { } + public void ReportExceptionDetails(Exception exception) { } + public void ReportSkipValidationSuggestion() { } + public void ReportSupportHelp() { } + + private sealed class TestMultiFileOutputReport : IMultiFileOutputReport + { + public void AddFile(string fileName, string directory, string sizeFormatted, int lines) { } + public void Complete(int fileCount, string totalSizeFormatted, int totalLines) { } + } + } } diff --git a/src/Refitter.Tests/OutputPlannerTests.cs b/src/Refitter.Tests/OutputPlannerTests.cs index 0e3d7d9ee..21b40c562 100644 --- a/src/Refitter.Tests/OutputPlannerTests.cs +++ b/src/Refitter.Tests/OutputPlannerTests.cs @@ -147,4 +147,131 @@ public void PlanSingleFile_Preserves_Content() planned.Content.Should().Be("// generated"); planned.Path.Should().Be(Path.Combine("Generated", "Api.cs")); } + + [Test] + public void SingleFile_SettingsFile_CliOverride_Output_Uses_CliPath() + { + var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); + var settings = new Settings + { + SettingsFilePath = settingsFilePath, + OutputPath = Path.Combine("CustomCli", "Override.cs") + }; + var refitSettings = new RefitGeneratorSettings { OutputFolder = "./Generated", OutputFilename = "Output.cs" }; + + var expected = Path.Combine( + Path.GetDirectoryName(settingsFilePath)!, + "CustomCli", + "Override.cs"); + OutputPlanner.GetSingleFileOutputPath(settings, refitSettings).Should().Be(expected); + } + + [Test] + public void SingleFile_SettingsFile_NoOutputOverride_Uses_SettingsFile_OutputFolder_And_Filename() + { + var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); + var settings = new Settings { SettingsFilePath = settingsFilePath, OutputPath = Settings.DefaultOutputPath }; + var refitSettings = new RefitGeneratorSettings { OutputFolder = "./CustomFolder", OutputFilename = "CustomOutput.cs" }; + + var expected = Path.Combine(Path.GetDirectoryName(settingsFilePath)!, "./CustomFolder", "CustomOutput.cs"); + OutputPlanner.GetSingleFileOutputPath(settings, refitSettings).Should().Be(expected); + } + + [Test] + public void SingleFile_SettingsFile_DefaultOutputFolder_Uses_DefaultFolder() + { + var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); + var settings = new Settings { SettingsFilePath = settingsFilePath, OutputPath = Settings.DefaultOutputPath }; + var refitSettings = new RefitGeneratorSettings { OutputFilename = "ApiClient.cs" }; + + var expected = Path.Combine( + Path.GetDirectoryName(settingsFilePath)!, + RefitGeneratorSettings.DefaultOutputFolder, + "ApiClient.cs"); + OutputPlanner.GetSingleFileOutputPath(settings, refitSettings).Should().Be(expected); + } + + [Test] + public void SingleFile_SettingsFile_Rooted_Path_Does_Not_Combine_With_Root() + { + var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); + var settings = new Settings { SettingsFilePath = settingsFilePath, OutputPath = Settings.DefaultOutputPath }; + var refitSettings = new RefitGeneratorSettings { OutputFolder = Path.GetTempPath() }; + + var expected = Path.Combine(Path.GetTempPath(), "Output.cs"); + OutputPlanner.GetSingleFileOutputPath(settings, refitSettings).Should().Be(expected); + } + + [Test] + public void SingleFile_DirectCli_Defaults_To_DefaultOutputPath() + { + var settings = new Settings { SettingsFilePath = null, OutputPath = Settings.DefaultOutputPath }; + var refitSettings = new RefitGeneratorSettings(); + + OutputPlanner.GetSingleFileOutputPath(settings, refitSettings).Should().Be(Settings.DefaultOutputPath); + } + + [Test] + public void SingleFile_DirectCli_Empty_SettingsFilePath_Defaults() + { + var settings = new Settings { SettingsFilePath = string.Empty, OutputPath = Settings.DefaultOutputPath }; + var refitSettings = new RefitGeneratorSettings(); + + OutputPlanner.GetSingleFileOutputPath(settings, refitSettings).Should().Be(Settings.DefaultOutputPath); + } + + [Test] + public void MultiFile_DirectCli_Default_Output_Uses_Current_Directory() + { + var settings = new Settings { OutputPath = Settings.DefaultOutputPath }; + var refitSettings = new RefitGeneratorSettings { OutputFolder = "./Generated" }; + + OutputPlanner.GetMultiFileOutputPath(settings, refitSettings, new GeneratedCode("RefitInterfaces", "// code")) + .Should().Be(Path.Combine(".", "RefitInterfaces.cs")); + } + + [Test] + public void MultiFile_SettingsFile_Uses_RefitGeneratorSettings_OutputFolder() + { + var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); + var settings = new Settings { SettingsFilePath = settingsFilePath, OutputPath = Settings.DefaultOutputPath }; + var refitSettings = new RefitGeneratorSettings { OutputFolder = "./ApiClient" }; + + var expected = Path.Combine(Path.GetDirectoryName(settingsFilePath)!, "./ApiClient", "RefitInterfaces.cs"); + OutputPlanner.GetMultiFileOutputPath(settings, refitSettings, new GeneratedCode("RefitInterfaces", "// code")) + .Should().Be(expected); + } + + [Test] + public void MultiFile_SettingsFile_Rooted_OutputFolder_Does_Not_Combine_With_Root() + { + var settingsFilePath = Path.Combine(Path.GetTempPath(), "Projects", "MyApi", "petstore.refitter"); + var settings = new Settings { SettingsFilePath = settingsFilePath, OutputPath = Settings.DefaultOutputPath }; + var refitSettings = new RefitGeneratorSettings { OutputFolder = Path.GetTempPath() }; + + var expected = Path.Combine(Path.GetTempPath(), "RefitInterfaces.cs"); + OutputPlanner.GetMultiFileOutputPath(settings, refitSettings, new GeneratedCode("RefitInterfaces", "// code")) + .Should().Be(expected); + } + + [Test] + public void ShouldReroute_Is_False_When_Contracts_Folder_Equals_DefaultOutputFolder() + { + var refitSettings = new RefitGeneratorSettings { ContractsOutputFolder = "./Generated" }; + + OutputPlanner.ShouldRerouteToContractsFolder(refitSettings, new GeneratedCode(TypenameConstants.Contracts, "// code")) + .Should().BeFalse(); + } + + [Test] + public void GetContractsOutputPath_Without_SettingsFilePath_Uses_ContractsFolder_Directly() + { + var settings = new Settings(); + var refitSettings = new RefitGeneratorSettings { ContractsOutputFolder = "./Contracts" }; + var outputFile = new GeneratedCode(TypenameConstants.Contracts, "// code"); + + var contractsFolder = Path.GetFullPath("./Contracts"); + OutputPlanner.GetContractsOutputPath(settings, refitSettings, outputFile) + .Should().Be(Path.Combine(contractsFolder, TypenameConstants.Contracts + ".cs")); + } } diff --git a/src/Refitter.Tests/Parameters/ParameterExtractorPrivateCoverageTests.cs b/src/Refitter.Tests/Parameters/ParameterExtractorPrivateCoverageTests.cs index d97183769..7cb4872d8 100644 --- a/src/Refitter.Tests/Parameters/ParameterExtractorPrivateCoverageTests.cs +++ b/src/Refitter.Tests/Parameters/ParameterExtractorPrivateCoverageTests.cs @@ -1,9 +1,11 @@ using System.Reflection; using System.Runtime.CompilerServices; +using System.Text; using FluentAssertions; using NJsonSchema; using NSwag; using NSwag.CodeGeneration.CSharp.Models; +using NSwag.CodeGeneration.Models; using Refitter.Core; namespace Refitter.Tests.Parameters; @@ -320,6 +322,336 @@ public void GetParameters_Does_Not_Mutate_Query_Parameter_Collection_When_Genera operationModel.Parameters.Should().ContainInOrder(firstParameter, secondParameter); } + [Test] + public void ReplaceUnsafeCharacters_Delegates_To_ParameterShared() + { + var result = InvokePrivate( + "ReplaceUnsafeCharacters", + [typeof(string)], + "unsafe-name!"); + result.Should().NotBeNullOrWhiteSpace(); + } + + [Test] + public void ReOrderNullableParameters_Delegates_To_OptionalParameterReorderer() + { + var parameterModels = new List(); + var result = InvokePrivate>( + "ReOrderNullableParameters", + [typeof(List), typeof(RefitGeneratorSettings), typeof(ICollection)], + new List { "string a", "int? b = default" }, + new RefitGeneratorSettings(), + parameterModels); + + result.Should().NotBeNull(); + } + + [Test] + public void FormatDoubleLiteral_Returns_AsIs_When_Contains_Dot() + { + var result = InvokePrivate( + "FormatDoubleLiteral", + [typeof(string)], + "3.14"); + result.Should().Be("3.14"); + } + + [Test] + public void FormatDoubleLiteral_Returns_AsIs_When_Contains_Exponent() + { + var result = InvokePrivate( + "FormatDoubleLiteral", + [typeof(string)], + "1.5e10"); + result.Should().Be("1.5e10"); + + var resultUpper = InvokePrivate( + "FormatDoubleLiteral", + [typeof(string)], + "1.5E10"); + resultUpper.Should().Be("1.5E10"); + } + + [Test] + public void FormatDoubleLiteral_Appends_PointZero_For_Integer_String() + { + var result = InvokePrivate( + "FormatDoubleLiteral", + [typeof(string)], + "42"); + result.Should().Be("42.0"); + } + + [Test] + public void GetBodyAttribute_Returns_Body_For_String_Parameter() + { + var param = CreateParameterModel("body", "body", "string"); + var result = InvokePrivate( + "GetBodyAttribute", + [typeof(CSharpParameterModel), typeof(RefitGeneratorSettings)], + param, + new RefitGeneratorSettings()); + result.Should().Be("Body"); + } + + [Test] + public void GetBodyAttribute_Returns_Serialized_For_Object_Parameter() + { + var param = CreateParameterModel("body", "body", "object"); + var result = InvokePrivate( + "GetBodyAttribute", + [typeof(CSharpParameterModel), typeof(RefitGeneratorSettings)], + param, + new RefitGeneratorSettings()); + result.Should().Be("Body(BodySerializationMethod.Serialized)"); + } + + [Test] + public void GetQueryAttribute_Returns_Query_Attribute() + { + var param = CreateParameterModel("q", "q"); + var result = InvokePrivate( + "GetQueryAttribute", + [typeof(CSharpParameterModel), typeof(RefitGeneratorSettings)], + param, + new RefitGeneratorSettings()); + result.Should().NotBeNull(); + } + + [Test] + public void JoinAttributes_Returns_Empty_For_Empty_Input() + { + var method = typeof(ParameterExtractor).GetMethod( + "JoinAttributes", + BindingFlags.NonPublic | BindingFlags.Static); + method.Should().NotBeNull(); + + var result = (string)method!.Invoke(null, [Array.Empty()])!; + result.Should().Be(string.Empty); + } + + [Test] + public void JoinAttributes_Returns_Combined_Attributes() + { + var method = typeof(ParameterExtractor).GetMethod( + "JoinAttributes", + BindingFlags.NonPublic | BindingFlags.Static); + method.Should().NotBeNull(); + + var result = (string)method!.Invoke(null, [new[] { "AliasAs(\"name\")", "Query()" }])!; + result.Should().Be("[AliasAs(\"name\"), Query()] "); + } + + [Test] + public void JoinAttributes_Returns_Single_Attribute() + { + var method = typeof(ParameterExtractor).GetMethod( + "JoinAttributes", + BindingFlags.NonPublic | BindingFlags.Static); + method.Should().NotBeNull(); + + var result = (string)method!.Invoke(null, [new[] { "AliasAs(\"name\")" }])!; + result.Should().Be("[AliasAs(\"name\")] "); + } + + [Test] + public void GetParameterType_Uses_CSharpProvider_Model_Type() + { + var param = CreateParameterModel("param1", "param1", "int"); + var result = InvokePrivate( + "GetParameterType", + [typeof(ParameterModelBase), typeof(RefitGeneratorSettings)], + param, + new RefitGeneratorSettings()); + result.Should().Be("int"); + } + + [Test] + public void GetQueryParameterType_Returns_Query_Parameter_Type() + { + var param = CreateParameterModel("q", "q", "string"); + var result = InvokePrivate( + "GetQueryParameterType", + [typeof(ParameterModelBase), typeof(RefitGeneratorSettings)], + param, + new RefitGeneratorSettings()); + result.Should().NotBeNull(); + } + + [Test] + public void FindSupportedType_Passes_Through_Type_Name() + { + var result = InvokePrivate( + "FindSupportedType", + [typeof(string)], + "string"); + result.Should().Be("string"); + } + + [Test] + public void ConvertToVariableName_Replaces_Unsafe_Characters() + { + var result = InvokePrivate( + "ConvertToVariableName", + [typeof(string)], + "field-name"); + result.Should().Be("field_name"); + } + + [Test] + public void ConvertToVariableName_Returns_Value_For_Empty_String() + { + var result = InvokePrivate( + "ConvertToVariableName", + [typeof(string)], + string.Empty); + result.Should().Be("value"); + } + + [Test] + public void GetVariableName_Returns_VariableName_From_Model() + { + var param = CreateParameterModel("param-name", "paramName"); + var result = InvokePrivate( + "GetVariableName", + [typeof(ParameterModelBase)], + param); + result.Should().Be("paramName"); + } + + [Test] + public void AppendXmlDocComment_Extends_CodeBuilder() + { + var method = typeof(ParameterExtractor).GetMethod( + "AppendXmlDocComment", + BindingFlags.NonPublic | BindingFlags.Static, + null, + [typeof(string), typeof(StringBuilder)], + null); + method.Should().NotBeNull(); + + var codeBuilder = new StringBuilder(); + method!.Invoke(null, ["Some description", codeBuilder]); + codeBuilder.ToString().Should().Contain("Some description"); + } + + [Test] + public void GetQueryParameters_With_No_Dynamic_Returns_Simple_Extraction() + { + var param = CreateParameterModel("query", "query", "string", + parameter: new OpenApiParameter + { + Name = "query", + Kind = OpenApiParameterKind.Query, + Schema = new JsonSchema { Type = JsonObjectType.String } + }); + var operationModel = CreateOperationModel(param); + var settings = new RefitGeneratorSettings(); + + var method = typeof(ParameterExtractor).GetMethod( + "GetQueryParameters", + BindingFlags.NonPublic | BindingFlags.Static, + null, + [typeof(CSharpOperationModel), typeof(RefitGeneratorSettings), typeof(string), typeof(string).MakeByRefType()], + null); + method.Should().NotBeNull(); + + var args = new object?[] { operationModel, settings, "QueryParams", null }; + var result = (List)method!.Invoke(null, args)!; + + result.Should().ContainSingle().Which.Should().Contain("query"); + } + + [Test] + public void GetQueryParameters_With_Dynamic_And_All_Nullable() + { + var firstParameter = CreateParameterModel( + "query", + "query", + type: "string?", + parameter: new OpenApiParameter + { + Name = "query", + Kind = OpenApiParameterKind.Query, + Schema = new JsonSchema { Type = JsonObjectType.String } + }); + var secondParameter = CreateParameterModel( + "page", + "page", + type: "int?", + parameter: new OpenApiParameter + { + Name = "page", + Kind = OpenApiParameterKind.Query, + Schema = new JsonSchema { Type = JsonObjectType.Integer } + }); + var operationModel = CreateOperationModel(firstParameter, secondParameter); + + var method = typeof(ParameterExtractor).GetMethod( + "GetQueryParameters", + BindingFlags.NonPublic | BindingFlags.Static, + null, + [typeof(CSharpOperationModel), typeof(RefitGeneratorSettings), typeof(string), typeof(string).MakeByRefType()], + null); + method.Should().NotBeNull(); + + var args = new object?[] + { + operationModel, + new RefitGeneratorSettings { UseDynamicQuerystringParameters = true }, + "SearchQueryParams", + null + }; + var result = (List)method!.Invoke(null, args)!; + + result.Should().ContainSingle().Which.Should().Be("[Query] SearchQueryParams? queryParams"); + } + + [Test] + public void GetQueryParameters_With_Dynamic_And_Not_All_Nullable() + { + var firstParameter = CreateParameterModel( + "query", + "query", + type: "string", + parameter: new OpenApiParameter + { + Name = "query", + Kind = OpenApiParameterKind.Query, + Schema = new JsonSchema { Type = JsonObjectType.String } + }); + var secondParameter = CreateParameterModel( + "page", + "page", + type: "int", + parameter: new OpenApiParameter + { + Name = "page", + Kind = OpenApiParameterKind.Query, + Schema = new JsonSchema { Type = JsonObjectType.Integer } + }); + var operationModel = CreateOperationModel(firstParameter, secondParameter); + + var method = typeof(ParameterExtractor).GetMethod( + "GetQueryParameters", + BindingFlags.NonPublic | BindingFlags.Static, + null, + [typeof(CSharpOperationModel), typeof(RefitGeneratorSettings), typeof(string), typeof(string).MakeByRefType()], + null); + method.Should().NotBeNull(); + + var args = new object?[] + { + operationModel, + new RefitGeneratorSettings { UseDynamicQuerystringParameters = true }, + "SearchQueryParams", + null + }; + var result = (List)method!.Invoke(null, args)!; + + result.Should().ContainSingle().Which.Should().Be("[Query] SearchQueryParams queryParams"); + } + private static T InvokePrivate(string methodName, Type[] parameterTypes, params object?[] arguments) { var method = typeof(ParameterExtractor).GetMethod( diff --git a/src/Refitter.Tests/RichGenerationReporterTests.cs b/src/Refitter.Tests/RichGenerationReporterTests.cs new file mode 100644 index 000000000..7b678825b --- /dev/null +++ b/src/Refitter.Tests/RichGenerationReporterTests.cs @@ -0,0 +1,221 @@ +using FluentAssertions; +using Microsoft.OpenApi; +using Refitter.Core; +using Refitter.Validation; +using TUnit.Core; + +namespace Refitter.Tests; + +/// +/// Smoke tests for . These exercise every method +/// for line/branch coverage; the output is not asserted because the methods write to +/// AnsiConsole/Spectre.Console which cannot be captured in unit tests. +/// A clean run (no exception) is the success criterion. +/// +public class RichGenerationReporterTests +{ + [Test] + public void ReportHeader_Does_Not_Throw() + { + var act = () => new RichGenerationReporter().ReportHeader("1.2.3"); + act.Should().NotThrow(); + } + + [Test] + public void ReportSupportKey_Does_Not_Throw() + { + var act = () => new RichGenerationReporter().ReportSupportKey("TEST-KEY-123"); + act.Should().NotThrow(); + } + + [Test] + [NotInParallel("SpectreConsole")] + public async Task ReportSingleFileGenerationProgressAsync_Does_Not_Throw() + { + await new RichGenerationReporter().ReportSingleFileGenerationProgressAsync(); + } + + [Test] + public void ReportSingleFileOutput_Does_Not_Throw() + { + var act = () => + new RichGenerationReporter().ReportSingleFileOutput("Output.cs", "/tmp", "12 KB", 300); + act.Should().NotThrow(); + } + + [Test] + [NotInParallel("SpectreConsole")] + public async Task GenerateMultipleFilesWithProgressAsync_Invokes_Generator() + { + var called = false; + var result = await new RichGenerationReporter() + .GenerateMultipleFilesWithProgressAsync(() => + { + called = true; + return new GeneratorOutput([]); + }); + + called.Should().BeTrue(); + result.Should().NotBeNull(); + } + + [Test] + public void BeginMultiFileOutput_AddFile_And_Complete_Do_Not_Throw() + { + var act = () => + { + var report = new RichGenerationReporter().BeginMultiFileOutput(); + report.AddFile("Api.cs", "/tmp", "5 KB", 100); + report.Complete(1, "5 KB", 100); + }; + + act.Should().NotThrow(); + } + + [Test] + public void ReportFileWritten_Does_Not_Throw() + { + var act = () => new RichGenerationReporter().ReportFileWritten("/output/Api.cs"); + act.Should().NotThrow(); + } + + [Test] + [NotInParallel("SpectreConsole")] + public async Task ValidateWithProgressAsync_Invokes_Validator() + { + var called = false; + await new RichGenerationReporter().ValidateWithProgressAsync(async () => + { + called = true; + return await Task.FromResult(new OpenApiValidationResult( + new Microsoft.OpenApi.Reader.OpenApiDiagnostic(), + new OpenApiStats())); + }); + + called.Should().BeTrue(); + } + + [Test] + public void ReportValidationFailed_Does_Not_Throw() + { + var act = () => new RichGenerationReporter().ReportValidationFailed(); + act.Should().NotThrow(); + } + + [Test] + public void ReportValidationDiagnostic_Error_Does_Not_Throw() + { + var act = () => new RichGenerationReporter().ReportValidationDiagnostic( + new OpenApiError("field", "Something went wrong"), + isError: true); + act.Should().NotThrow(); + } + + [Test] + public void ReportValidationDiagnostic_Warning_Does_Not_Throw() + { + var act = () => new RichGenerationReporter().ReportValidationDiagnostic( + new OpenApiError("field", "A warning"), + isError: false); + act.Should().NotThrow(); + } + + [Test] + public void ReportValidationStatistics_Does_Not_Throw() + { + var result = new OpenApiValidationResult( + new Microsoft.OpenApi.Reader.OpenApiDiagnostic(), + new OpenApiStats()); + + var act = () => new RichGenerationReporter().ReportValidationStatistics(result); + act.Should().NotThrow(); + } + + [Test] + public void ReportSuccess_SingleFile_Does_Not_Throw() + { + var act = () => + new RichGenerationReporter().ReportSuccess(TimeSpan.FromMilliseconds(1234), multipleFiles: false); + act.Should().NotThrow(); + } + + [Test] + public void ReportSuccess_MultipleFiles_Does_Not_Throw() + { + var act = () => + new RichGenerationReporter().ReportSuccess(TimeSpan.FromMilliseconds(1234), multipleFiles: true); + act.Should().NotThrow(); + } + + [Test] + public void ReportDonationBanner_Does_Not_Throw() + { + var act = () => new RichGenerationReporter().ReportDonationBanner(); + act.Should().NotThrow(); + } + + [Test] + public void ReportConfigurationWarnings_Does_Not_Throw() + { + var warnings = new List<(string Title, string Description)> + { + ("Title1", "Desc1"), + ("Title2", "Desc2") + }; + + var act = () => new RichGenerationReporter().ReportConfigurationWarnings(warnings); + act.Should().NotThrow(); + } + + [Test] + public void ReportAllPathsFilteredWarning_Does_Not_Throw() + { + var act = () => new RichGenerationReporter() + .ReportAllPathsFilteredWarning(["^/pets", "^/users"]); + act.Should().NotThrow(); + } + + [Test] + public void ReportSettingsFileGenerated_Does_Not_Throw() + { + var act = () => + new RichGenerationReporter().ReportSettingsFileGenerated("/tmp/petstore.refitter"); + act.Should().NotThrow(); + } + + [Test] + public void ReportGenerationFailed_Does_Not_Throw() + { + var act = () => new RichGenerationReporter().ReportGenerationFailed(); + act.Should().NotThrow(); + } + + [Test] + public void ReportUnsupportedVersion_Does_Not_Throw() + { + var act = () => new RichGenerationReporter().ReportUnsupportedVersion("1.0"); + act.Should().NotThrow(); + } + + [Test] + public void ReportExceptionDetails_Does_Not_Throw() + { + var act = () => new RichGenerationReporter() + .ReportExceptionDetails(new InvalidOperationException("boom")); + act.Should().NotThrow(); + } + + [Test] + public void ReportSkipValidationSuggestion_Does_Not_Throw() + { + var act = () => new RichGenerationReporter().ReportSkipValidationSuggestion(); + act.Should().NotThrow(); + } + + [Test] + public void ReportSupportHelp_Does_Not_Throw() + { + var act = () => new RichGenerationReporter().ReportSupportHelp(); + act.Should().NotThrow(); + } +}