diff --git a/src/SignhostAPIClient.IntegrationTests/BadRequestTests.cs b/src/SignhostAPIClient.IntegrationTests/BadRequestTests.cs new file mode 100644 index 00000000..a9e73731 --- /dev/null +++ b/src/SignhostAPIClient.IntegrationTests/BadRequestTests.cs @@ -0,0 +1,126 @@ +using System.IO; +using System.Threading.Tasks; +using System; +using FluentAssertions; +using Signhost.APIClient.Rest.DataObjects; +using Signhost.APIClient.Rest.ErrorHandling; +using Xunit; + +namespace Signhost.APIClient.Rest.IntegrationTests; + +public class BadRequestTests + : IntegrationTestBase +{ + [Theory] + [MemberData(nameof(BadCreateRequestTestCases))] + public async Task Given_invalid_create_request_When_transaction_is_created_Then_BadRequestException_is_thrown( + CreateTransactionRequest badRequest, + string expectedErrorMessage) + { + // Act + Func act = () => Client.CreateTransactionAsync(badRequest); + + // Assert + var exception = await act.Should() + .ThrowAsync(); + exception.Which.Message.Should().Be(expectedErrorMessage); + exception.Which.ResponseBody.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task Given_invalid_file_name_When_file_is_uploaded_Then_BadRequestException_is_thrown() + { + // Arrange + CreateTransactionRequest transaction = new() { + SendEmailNotifications = false, + Signers = [ + new() { + Id = "signer1", + Email = "test@example.com", + SendSignRequest = false, + } + ] + }; + + var createdTransaction = await Client.CreateTransactionAsync(transaction); + string pdfPath = Path.Combine("TestFiles", "small-example-pdf-file.pdf"); + await using var fileStream = File.OpenRead(pdfPath); + + // Act + Func act = () => Client.AddOrReplaceFileToTransactionAsync( + fileStream, + createdTransaction.Id, + "invalid|filename.pdf", + new FileUploadOptions()); + + // Assert + var exception = await act.Should() + .ThrowAsync(); + exception.Which.Message.Should().Be("invalid|filename.pdf is not a valid file name."); + exception.Which.ResponseBody.Should().NotBeNullOrEmpty(); + } + + public static TheoryData BadCreateRequestTestCases => + new() { + { + new CreateTransactionRequest { + SendEmailNotifications = false, + DaysToExpire = 1, + Signers = [ + new() { + Email = "test@example.com", + SendSignRequest = true, + SignRequestMessage = "Please sign.", + DaysToRemind = 30, + } + ], + }, + "The days to remind must be lower than the days to expire." + }, + { + new CreateTransactionRequest { + SendEmailNotifications = false, + Signers = [ + new() { + Email = "test@example.com", + SendSignRequest = true, + SignRequestMessage = null, + } + ], + }, + "The sign request message is null or empty." + }, + { + new CreateTransactionRequest { + SendEmailNotifications = false, + Signers = [ + new() { + Email = "test@example.com", + SendSignRequest = true, + SignRequestMessage = "Please sign.", + SignRequestSubject = "Subject\twith\ttabs", + } + ], + }, + "Signer Subject: Input contains one or more invalid characters." + }, + { + new CreateTransactionRequest { + SendEmailNotifications = false, + Signers = [ + new() { + Id = "duplicate-signer-id", + Email = "signer1@example.com", + SendSignRequest = false, + }, + new() { + Id = "duplicate-signer-id", + Email = "signer2@example.com", + SendSignRequest = false, + } + ], + }, + "The signer id is already used by another signer." + }, + }; +} diff --git a/src/SignhostAPIClient.IntegrationTests/IntegrationTestBase.cs b/src/SignhostAPIClient.IntegrationTests/IntegrationTestBase.cs new file mode 100644 index 00000000..34ac92da --- /dev/null +++ b/src/SignhostAPIClient.IntegrationTests/IntegrationTestBase.cs @@ -0,0 +1,30 @@ +using System; + +namespace Signhost.APIClient.Rest.IntegrationTests; + +public abstract class IntegrationTestBase + : IDisposable +{ + protected readonly SignhostApiClient Client; + + protected IntegrationTestBase() + { + var config = TestConfiguration.Instance; + + if (!config.IsConfigured) { + throw new InvalidOperationException("Integration tests are not configured."); + } + + SignhostApiClientSettings settings = new(config.AppKey, config.UserToken) { + Endpoint = config.ApiBaseUrl, + }; + + Client = new(settings); + } + + public void Dispose() + { + Client?.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/SignhostAPIClient.IntegrationTests/TransactionTests.cs b/src/SignhostAPIClient.IntegrationTests/TransactionTests.cs index 1d39224e..291a52bd 100644 --- a/src/SignhostAPIClient.IntegrationTests/TransactionTests.cs +++ b/src/SignhostAPIClient.IntegrationTests/TransactionTests.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Text.Json; using System.Threading.Tasks; using FluentAssertions; using Signhost.APIClient.Rest.DataObjects; @@ -10,25 +9,8 @@ namespace Signhost.APIClient.Rest.IntegrationTests; public class TransactionTests - : IDisposable + : IntegrationTestBase { - private readonly SignhostApiClient client; - private readonly TestConfiguration config; - - public TransactionTests() - { - config = TestConfiguration.Instance; - - if (!config.IsConfigured) { - throw new InvalidOperationException("Integration tests are not configured."); - } - - SignhostApiClientSettings settings = new(config.AppKey, config.UserToken) { - Endpoint = config.ApiBaseUrl, - }; - - client = new(settings); - } [Fact] public async Task Given_complex_transaction_When_created_and_started_Then_all_properties_are_correctly_persisted() @@ -109,7 +91,7 @@ public async Task Given_complex_transaction_When_created_and_started_Then_all_pr } // Act - Create transaction - var createdTransaction = await client.CreateTransactionAsync(transaction); + var createdTransaction = await Client.CreateTransactionAsync(transaction); // Assert - Creation properties createdTransaction.Should().NotBeNull(); @@ -200,17 +182,20 @@ public async Task Given_complex_transaction_When_created_and_started_Then_all_pr // Act - Upload file await using var fileStream = File.OpenRead(pdfPath); - await client.AddOrReplaceFileToTransactionAsync( + await Client.AddOrReplaceFileToTransactionAsync( fileStream, createdTransaction.Id, "test-document.pdf", new FileUploadOptions()); // Act - Start transaction - await client.StartTransactionAsync(createdTransaction.Id); + await Client.StartTransactionAsync(createdTransaction.Id); + + // No postbacks, so we need to wait a bit for the transaction to be processed. + await Task.Delay(TimeSpan.FromSeconds(5)); // Act - Retrieve final state - var finalTransaction = await client.GetTransactionAsync(createdTransaction.Id); + var finalTransaction = await Client.GetTransactionAsync(createdTransaction.Id); // Assert - Final transaction state finalTransaction.Should().NotBeNull(); @@ -279,7 +264,7 @@ public async Task Given_complex_file_metadata_When_added_to_transaction_Then_all ] }; - var createdTransaction = await client.CreateTransactionAsync(transaction); + var createdTransaction = await Client.CreateTransactionAsync(transaction); createdTransaction.Should().NotBeNull(); // Arrange - Create a very complex FileMeta @@ -433,7 +418,7 @@ public async Task Given_complex_file_metadata_When_added_to_transaction_Then_all }; // Act - Func act = () => client.AddOrReplaceFileMetaToTransactionAsync( + Func act = () => Client.AddOrReplaceFileMetaToTransactionAsync( fileMeta, createdTransaction.Id, "test-document.pdf"); @@ -441,10 +426,4 @@ public async Task Given_complex_file_metadata_When_added_to_transaction_Then_all // Assert await act.Should().NotThrowAsync(); } - - public void Dispose() - { - client?.Dispose(); - GC.SuppressFinalize(this); - } } diff --git a/src/SignhostAPIClient.Tests/SignhostApiClientTests.cs b/src/SignhostAPIClient.Tests/SignhostApiClientTests.cs index bd3d1dcd..41b056fa 100644 --- a/src/SignhostAPIClient.Tests/SignhostApiClientTests.cs +++ b/src/SignhostAPIClient.Tests/SignhostApiClientTests.cs @@ -661,4 +661,80 @@ public async Task When_a_minimal_response_is_retrieved_list_and_dictionaries_are result.Receivers.Should().BeEmpty(); result.Files.Should().BeEmpty(); } + + [Fact] + public async Task When_error_response_has_uppercase_Message_Then_exception_message_is_parsed_correctly() + { + MockHttpMessageHandler mockHttp = new(); + const string responseBody = """{"Message":"The signer id is already used by another signer."}"""; + mockHttp + .Expect(HttpMethod.Get, "http://localhost/api/transaction/transactionId") + .Respond(HttpStatusCode.BadRequest, new StringContent(responseBody)); + + using var httpClient = mockHttp.ToHttpClient(); + SignhostApiClient signhostApiClient = new(settings, httpClient); + + Func act = () => signhostApiClient.GetTransactionAsync("transactionId"); + var exception = await act.Should().ThrowAsync(); + exception.Which.Message.Should().Be("The signer id is already used by another signer."); + + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact] + public async Task When_error_response_has_lowercase_message_Then_exception_message_is_parsed_correctly() + { + MockHttpMessageHandler mockHttp = new(); + const string responseBody = """{"message":"Bad Request"}"""; + mockHttp + .Expect(HttpMethod.Get, "http://localhost/api/transaction/transactionId") + .Respond(HttpStatusCode.BadRequest, new StringContent(responseBody)); + + using var httpClient = mockHttp.ToHttpClient(); + SignhostApiClient signhostApiClient = new(settings, httpClient); + + Func act = () => signhostApiClient.GetTransactionAsync("transactionId"); + var exception = await act.Should().ThrowAsync(); + exception.Which.Message.Should().Be("Bad Request"); + + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact] + public async Task When_error_response_has_detail_but_no_message_Then_exception_message_uses_detail() + { + MockHttpMessageHandler mockHttp = new(); + const string responseBody = """{"type":"https://example.com/problem","detail":"Something went wrong."}"""; + mockHttp + .Expect(HttpMethod.Get, "http://localhost/api/transaction/transactionId") + .Respond(HttpStatusCode.BadRequest, new StringContent(responseBody)); + + using var httpClient = mockHttp.ToHttpClient(); + SignhostApiClient signhostApiClient = new(settings, httpClient); + + Func act = () => signhostApiClient.GetTransactionAsync("transactionId"); + var exception = await act.Should().ThrowAsync(); + exception.Which.Message.Should().Be("Something went wrong."); + + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact] + public async Task When_error_response_has_uppercase_Detail_but_no_message_Then_exception_message_uses_detail() + { + MockHttpMessageHandler mockHttp = new(); + const string responseBody = """{"Type":"https://example.com/problem","Detail":"A detailed error."}"""; + mockHttp + .Expect(HttpMethod.Get, "http://localhost/api/transaction/transactionId") + .Respond(HttpStatusCode.BadRequest, new StringContent(responseBody)); + + using var httpClient = mockHttp.ToHttpClient(); + SignhostApiClient signhostApiClient = new(settings, httpClient); + + Func act = () => signhostApiClient.GetTransactionAsync("transactionId"); + var exception = await act.Should().ThrowAsync(); + exception.Which.Message.Should().Be("A detailed error."); + + mockHttp.VerifyNoOutstandingExpectation(); + } } diff --git a/src/SignhostAPIClient/Rest/ErrorHandling/HttpResponseMessageErrorHandlingExtensions.cs b/src/SignhostAPIClient/Rest/ErrorHandling/HttpResponseMessageErrorHandlingExtensions.cs index 491c9d4c..24624504 100644 --- a/src/SignhostAPIClient/Rest/ErrorHandling/HttpResponseMessageErrorHandlingExtensions.cs +++ b/src/SignhostAPIClient/Rest/ErrorHandling/HttpResponseMessageErrorHandlingExtensions.cs @@ -16,6 +16,13 @@ public static class HttpResponseMessageErrorHandlingExtensions private const string OutOfCreditsApiProblemType = "https://api.signhost.com/problem/subscription/out-of-credits"; + private const string DefaultErrorMessage = "Unknown Signhost error"; + private const string DefaultErrorType = "Unknown Signhost Error type"; + + private static readonly JsonSerializerOptions Options = new () { + PropertyNameCaseInsensitive = true, + }; + /// /// Throws an exception if the /// has an error code. @@ -57,18 +64,20 @@ public static async Task EnsureSignhostSuccessStatusCodeAsy return response; } - string errorType = string.Empty; - string errorMessage = "Unknown Signhost error"; + string errorType = DefaultErrorType; + string errorMessage = DefaultErrorMessage; string responseBody = string.Empty; if (response.Content is not null) { responseBody = await response.Content.ReadAsStringAsync() .ConfigureAwait(false); - var error = JsonSerializer.Deserialize(responseBody); + var error = JsonSerializer.Deserialize(responseBody, Options); - errorType = error?.Type ?? string.Empty; - errorMessage = error?.Message ?? "Unknown Signhost error"; + errorType = error?.Type ?? DefaultErrorType; + errorMessage = error?.Message + ?? error?.Detail + ?? DefaultErrorMessage; } SignhostRestApiClientException exception = response.StatusCode switch { @@ -93,10 +102,26 @@ public static async Task EnsureSignhostSuccessStatusCodeAsy private class ErrorResponse { + /// + /// The problem type URI (RFC 7807). Used to identify specific error + /// categories such as out-of-credits. + /// [JsonPropertyName("type")] - public string Type { get; set; } = string.Empty; + public string? Type { get; set; } + /// + /// Human-readable error message returned by the Signhost API + /// in its legacy error format, e.g. {"Message":"..."}. + /// [JsonPropertyName("message")] - public string Message { get; set; } = string.Empty; + public string? Message { get; set; } + + /// + /// Human-readable explanation of the problem as defined by RFC 7807 + /// problem details, e.g. {"detail":"..."}. Used as a fallback + /// when is absent. + /// + [JsonPropertyName("detail")] + public string? Detail { get; set; } } }