diff --git a/CoreZipCode.Tests/Interfaces/ApiHandlerTest.cs b/CoreZipCode.Tests/Interfaces/ApiHandlerTest.cs new file mode 100644 index 0000000..7a0c420 --- /dev/null +++ b/CoreZipCode.Tests/Interfaces/ApiHandlerTest.cs @@ -0,0 +1,293 @@ +using CoreZipCode.Interfaces; +using Moq; +using Moq.Protected; +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace CoreZipCode.Tests.Interfaces +{ + public class ApiHandlerTest + { + [Fact] + public void Constructor_Without_Parameters_Should_Create_Instance() + { + var handler = new ApiHandler(); + + Assert.NotNull(handler); + } + + [Fact] + public void Constructor_With_HttpClient_Should_Create_Instance() + { + var httpClient = new HttpClient(); + + var handler = new ApiHandler(httpClient); + + Assert.NotNull(handler); + } + + [Fact] + public void Constructor_With_Null_HttpClient_Should_Throw_ArgumentNullException() + { + Assert.Throws(() => new ApiHandler(null)); + } + + [Fact] + public async Task CallApiAsync_With_Null_Url_Should_Return_BadRequest() + { + var handler = new ApiHandler(); + + var result = await handler.CallApiAsync(null); + + Assert.True(result.IsFailure); + Assert.Equal(HttpStatusCode.BadRequest, result.Error.StatusCode); + Assert.Equal("URL cannot be null or empty.", result.Error.Message); + } + + [Fact] + public async Task CallApiAsync_With_Empty_Url_Should_Return_BadRequest() + { + var handler = new ApiHandler(); + + var result = await handler.CallApiAsync(string.Empty); + + Assert.True(result.IsFailure); + Assert.Equal(HttpStatusCode.BadRequest, result.Error.StatusCode); + Assert.Equal("URL cannot be null or empty.", result.Error.Message); + } + + [Fact] + public async Task CallApiAsync_With_Whitespace_Url_Should_Return_BadRequest() + { + var handler = new ApiHandler(); + + var result = await handler.CallApiAsync(" "); + + Assert.True(result.IsFailure); + Assert.Equal(HttpStatusCode.BadRequest, result.Error.StatusCode); + Assert.Equal("URL cannot be null or empty.", result.Error.Message); + } + + [Fact] + public async Task CallApiAsync_With_Success_Response_Should_Return_Success() + { + var expectedBody = "{\"data\":\"test\"}"; + var handlerMock = new Mock(); + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent(expectedBody) + }); + var httpClient = new HttpClient(handlerMock.Object); + var handler = new ApiHandler(httpClient); + + var result = await handler.CallApiAsync("https://test.com"); + + Assert.True(result.IsSuccess); + Assert.Equal(expectedBody, result.Value); + } + + [Fact] + public async Task CallApiAsync_With_NonSuccess_Response_Should_Return_Failure() + { + var responseBody = "{\"error\":\"not found\"}"; + var handlerMock = new Mock(); + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.NotFound, + ReasonPhrase = "Not Found", + Content = new StringContent(responseBody) + }); + var httpClient = new HttpClient(handlerMock.Object); + var handler = new ApiHandler(httpClient); + + var result = await handler.CallApiAsync("https://test.com"); + + Assert.True(result.IsFailure); + Assert.Equal(HttpStatusCode.NotFound, result.Error.StatusCode); + Assert.Contains("404", result.Error.Message); + Assert.Contains("NotFound", result.Error.Message); + Assert.Equal(responseBody, result.Error.ResponseBody); + } + + [Fact] + public async Task CallApiAsync_With_HttpRequestException_Should_Return_ServiceUnavailable() + { + var handlerMock = new Mock(); + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ThrowsAsync(new HttpRequestException("Connection failed")); + var httpClient = new HttpClient(handlerMock.Object); + var handler = new ApiHandler(httpClient); + + var result = await handler.CallApiAsync("https://test.com"); + + Assert.True(result.IsFailure); + Assert.Equal(HttpStatusCode.ServiceUnavailable, result.Error.StatusCode); + Assert.Equal("Network or connection error.", result.Error.Message); + } + + [Fact] + public async Task CallApiAsync_With_TaskCanceledException_Should_Return_RequestTimeout() + { + var handlerMock = new Mock(); + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ThrowsAsync(new TaskCanceledException("The request timed out")); + var httpClient = new HttpClient(handlerMock.Object); + var handler = new ApiHandler(httpClient); + + var result = await handler.CallApiAsync("https://test.com"); + + Assert.True(result.IsFailure); + Assert.Equal(HttpStatusCode.RequestTimeout, result.Error.StatusCode); + Assert.Equal("Request timed out.", result.Error.Message); + } + + [Fact] + public async Task CallApiAsync_With_OperationCanceledException_Should_Return_BadRequest() + { + var handlerMock = new Mock(); + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ThrowsAsync(new OperationCanceledException("Operation was cancelled")); + var httpClient = new HttpClient(handlerMock.Object); + var handler = new ApiHandler(httpClient); + + var result = await handler.CallApiAsync("https://test.com"); + + Assert.True(result.IsFailure); + Assert.Equal(HttpStatusCode.BadRequest, result.Error.StatusCode); + Assert.Equal("Request was cancelled.", result.Error.Message); + } + + [Fact] + public async Task CallApiAsync_With_Generic_Exception_Should_Return_InternalServerError() + { + var handlerMock = new Mock(); + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ThrowsAsync(new InvalidOperationException("Something went wrong")); + var httpClient = new HttpClient(handlerMock.Object); + var handler = new ApiHandler(httpClient); + + var result = await handler.CallApiAsync("https://test.com"); + + Assert.True(result.IsFailure); + Assert.Equal(HttpStatusCode.InternalServerError, result.Error.StatusCode); + Assert.Equal("Unexpected error.", result.Error.Message); + } + + [Fact] + public async Task CallApiAsync_With_Multiple_Success_Codes_Should_Return_Success() + { + var testCases = new[] + { + HttpStatusCode.OK, + HttpStatusCode.Created, + HttpStatusCode.Accepted, + HttpStatusCode.NoContent + }; + + foreach (var statusCode in testCases) + { + var handlerMock = new Mock(); + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = statusCode, + Content = new StringContent("success") + }); + var httpClient = new HttpClient(handlerMock.Object); + var handler = new ApiHandler(httpClient); + + var result = await handler.CallApiAsync("https://test.com"); + + Assert.True(result.IsSuccess); + } + } + + [Fact] + public async Task CallApiAsync_With_Multiple_Error_Codes_Should_Return_Failure() + { + var testCases = new[] + { + HttpStatusCode.BadRequest, + HttpStatusCode.Unauthorized, + HttpStatusCode.Forbidden, + HttpStatusCode.NotFound, + HttpStatusCode.InternalServerError, + HttpStatusCode.BadGateway, + HttpStatusCode.ServiceUnavailable + }; + + foreach (var statusCode in testCases) + { + var handlerMock = new Mock(); + handlerMock + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = statusCode, + Content = new StringContent("error") + }); + var httpClient = new HttpClient(handlerMock.Object); + var handler = new ApiHandler(httpClient); + + var result = await handler.CallApiAsync("https://test.com"); + + Assert.True(result.IsFailure); + Assert.Equal(statusCode, result.Error.StatusCode); + } + } + } +} \ No newline at end of file diff --git a/CoreZipCode.Tests/Result/ApiErrorTest.cs b/CoreZipCode.Tests/Result/ApiErrorTest.cs new file mode 100644 index 0000000..0ddc94a --- /dev/null +++ b/CoreZipCode.Tests/Result/ApiErrorTest.cs @@ -0,0 +1,292 @@ +using System; +using System.Net; +using CoreZipCode.Result; +using Xunit; + +namespace CoreZipCode.Tests.Result +{ + public class ApiErrorTest + { + [Fact] + public void Constructor_With_Required_Parameters_Should_Create_Instance() + { + var statusCode = HttpStatusCode.BadRequest; + var message = "Test error message"; + + var error = new ApiError(statusCode, message); + + Assert.Equal(statusCode, error.StatusCode); + Assert.Equal(message, error.Message); + Assert.Null(error.Detail); + Assert.Null(error.ResponseBody); + } + + [Fact] + public void Constructor_With_All_Parameters_Should_Create_Instance() + { + var statusCode = HttpStatusCode.NotFound; + var message = "Resource not found"; + var detail = "The requested resource does not exist"; + var responseBody = "{\"error\":\"not found\"}"; + + var error = new ApiError(statusCode, message, detail, responseBody); + + Assert.Equal(statusCode, error.StatusCode); + Assert.Equal(message, error.Message); + Assert.Equal(detail, error.Detail); + Assert.Equal(responseBody, error.ResponseBody); + } + + [Fact] + public void Constructor_With_Detail_Only_Should_Create_Instance() + { + var statusCode = HttpStatusCode.InternalServerError; + var message = "Server error"; + var detail = "Database connection failed"; + + var error = new ApiError(statusCode, message, detail); + + Assert.Equal(statusCode, error.StatusCode); + Assert.Equal(message, error.Message); + Assert.Equal(detail, error.Detail); + Assert.Null(error.ResponseBody); + } + + [Fact] + public void Constructor_With_ResponseBody_Only_Should_Create_Instance() + { + var statusCode = HttpStatusCode.BadRequest; + var message = "Validation failed"; + var responseBody = "{\"errors\":[]}"; + + var error = new ApiError(statusCode, message, null, responseBody); + + Assert.Equal(statusCode, error.StatusCode); + Assert.Equal(message, error.Message); + Assert.Null(error.Detail); + Assert.Equal(responseBody, error.ResponseBody); + } + + [Fact] + public void Constructor_With_Null_Message_Should_Throw_ArgumentNullException() + { + var statusCode = HttpStatusCode.BadRequest; + + Assert.Throws(() => new ApiError(statusCode, null)); + } + + [Fact] + public void Constructor_With_Empty_Message_Should_Create_Instance() + { + var statusCode = HttpStatusCode.BadRequest; + var message = string.Empty; + + var error = new ApiError(statusCode, message); + + Assert.Equal(statusCode, error.StatusCode); + Assert.Equal(message, error.Message); + } + + [Fact] + public void Constructor_With_Different_StatusCodes_Should_Create_Instance() + { + var testCases = new[] + { + HttpStatusCode.OK, + HttpStatusCode.Created, + HttpStatusCode.BadRequest, + HttpStatusCode.Unauthorized, + HttpStatusCode.Forbidden, + HttpStatusCode.NotFound, + HttpStatusCode.MethodNotAllowed, + HttpStatusCode.InternalServerError, + HttpStatusCode.BadGateway, + HttpStatusCode.ServiceUnavailable, + HttpStatusCode.GatewayTimeout + }; + + foreach (var statusCode in testCases) + { + var error = new ApiError(statusCode, "Test message"); + + Assert.Equal(statusCode, error.StatusCode); + } + } + + [Fact] + public void ToString_Should_Return_Formatted_String() + { + var statusCode = HttpStatusCode.NotFound; + var message = "Resource not found"; + var error = new ApiError(statusCode, message); + + var result = error.ToString(); + + Assert.Equal("404 NotFound: Resource not found", result); + } + + [Fact] + public void ToString_With_BadRequest_Should_Return_Formatted_String() + { + var statusCode = HttpStatusCode.BadRequest; + var message = "Invalid input"; + var error = new ApiError(statusCode, message); + + var result = error.ToString(); + + Assert.Equal("400 BadRequest: Invalid input", result); + } + + [Fact] + public void ToString_With_InternalServerError_Should_Return_Formatted_String() + { + var statusCode = HttpStatusCode.InternalServerError; + var message = "Server error occurred"; + var error = new ApiError(statusCode, message); + + var result = error.ToString(); + + Assert.Equal("500 InternalServerError: Server error occurred", result); + } + + [Fact] + public void ToString_Should_Not_Include_Detail_Or_ResponseBody() + { + var statusCode = HttpStatusCode.BadRequest; + var message = "Validation failed"; + var detail = "Field X is required"; + var responseBody = "{\"error\":\"validation\"}"; + var error = new ApiError(statusCode, message, detail, responseBody); + + var result = error.ToString(); + + Assert.Equal("400 BadRequest: Validation failed", result); + Assert.DoesNotContain(detail, result); + Assert.DoesNotContain(responseBody, result); + } + + [Fact] + public void ToString_With_Empty_Message_Should_Return_Formatted_String() + { + var statusCode = HttpStatusCode.BadRequest; + var message = string.Empty; + var error = new ApiError(statusCode, message); + + var result = error.ToString(); + + Assert.Equal("400 BadRequest: ", result); + } + + [Fact] + public void Multiple_Instances_Should_Be_Independent() + { + var error1 = new ApiError(HttpStatusCode.BadRequest, "Error 1"); + var error2 = new ApiError(HttpStatusCode.NotFound, "Error 2"); + + Assert.NotEqual(error1.StatusCode, error2.StatusCode); + Assert.NotEqual(error1.Message, error2.Message); + } + + [Fact] + public void StatusCode_Should_Be_Immutable() + { + var error = new ApiError(HttpStatusCode.BadRequest, "Test"); + var statusCode = error.StatusCode; + + Assert.Equal(HttpStatusCode.BadRequest, statusCode); + Assert.Equal(HttpStatusCode.BadRequest, error.StatusCode); + } + + [Fact] + public void Message_Should_Be_Immutable() + { + var message = "Original message"; + var error = new ApiError(HttpStatusCode.BadRequest, message); + + Assert.Equal(message, error.Message); + } + + [Fact] + public void Detail_Should_Be_Immutable() + { + var detail = "Detailed information"; + var error = new ApiError(HttpStatusCode.BadRequest, "Message", detail); + + Assert.Equal(detail, error.Detail); + } + + [Fact] + public void ResponseBody_Should_Be_Immutable() + { + var responseBody = "{\"error\":\"test\"}"; + var error = new ApiError(HttpStatusCode.BadRequest, "Message", null, responseBody); + + Assert.Equal(responseBody, error.ResponseBody); + } + + [Fact] + public void Properties_Should_Match_Constructor_Parameters() + { + var statusCode = HttpStatusCode.Unauthorized; + var message = "Authentication required"; + var detail = "Token expired"; + var responseBody = "{\"auth\":false}"; + + var error = new ApiError(statusCode, message, detail, responseBody); + + Assert.Equal(statusCode, error.StatusCode); + Assert.Equal(message, error.Message); + Assert.Equal(detail, error.Detail); + Assert.Equal(responseBody, error.ResponseBody); + } + + [Fact] + public void Constructor_With_Whitespace_Message_Should_Create_Instance() + { + var statusCode = HttpStatusCode.BadRequest; + var message = " "; + + var error = new ApiError(statusCode, message); + + Assert.Equal(statusCode, error.StatusCode); + Assert.Equal(message, error.Message); + } + + [Fact] + public void Constructor_With_Long_Message_Should_Create_Instance() + { + var statusCode = HttpStatusCode.BadRequest; + var message = new string('x', 1000); + + var error = new ApiError(statusCode, message); + + Assert.Equal(statusCode, error.StatusCode); + Assert.Equal(message, error.Message); + } + + [Fact] + public void Constructor_With_Special_Characters_In_Message_Should_Create_Instance() + { + var statusCode = HttpStatusCode.BadRequest; + var message = "Error: & \"quotes\" & 'apostrophes'"; + + var error = new ApiError(statusCode, message); + + Assert.Equal(statusCode, error.StatusCode); + Assert.Equal(message, error.Message); + } + + [Fact] + public void ToString_With_Special_Characters_Should_Include_Them() + { + var statusCode = HttpStatusCode.BadRequest; + var message = "Error: & special"; + var error = new ApiError(statusCode, message); + + var result = error.ToString(); + + Assert.Contains("", result); + Assert.Contains("&", result); + } + } +} \ No newline at end of file diff --git a/CoreZipCode.Tests/Result/ResultTest.cs b/CoreZipCode.Tests/Result/ResultTest.cs new file mode 100644 index 0000000..7ca7790 --- /dev/null +++ b/CoreZipCode.Tests/Result/ResultTest.cs @@ -0,0 +1,318 @@ +using System; +using System.Net; +using CoreZipCode.Result; +using Xunit; + +namespace CoreZipCode.Tests.Result +{ + public class ResultTest + { + [Fact] + public void Success_Should_Create_Success_Result() + { + var value = "test data"; + + var result = Result.Success(value); + + Assert.True(result.IsSuccess); + Assert.False(result.IsFailure); + Assert.Equal(value, result.Value); + Assert.Null(result.Error); + } + + [Fact] + public void Success_With_Null_Value_Should_Create_Success_Result() + { + var result = Result.Success(null); + + Assert.True(result.IsSuccess); + Assert.False(result.IsFailure); + Assert.Null(result.Value); + Assert.Null(result.Error); + } + + [Fact] + public void Success_With_Int_Should_Create_Success_Result() + { + var value = 42; + + var result = Result.Success(value); + + Assert.True(result.IsSuccess); + Assert.False(result.IsFailure); + Assert.Equal(value, result.Value); + } + + [Fact] + public void Success_With_Object_Should_Create_Success_Result() + { + var value = new { Id = 1, Name = "Test" }; + + var result = Result.Success(value); + + Assert.True(result.IsSuccess); + Assert.False(result.IsFailure); + Assert.Equal(value, result.Value); + } + + [Fact] + public void Failure_Should_Create_Failure_Result() + { + var error = new ApiError(HttpStatusCode.BadRequest, "Error message"); + + var result = Result.Failure(error); + + Assert.False(result.IsSuccess); + Assert.True(result.IsFailure); + Assert.Equal(error, result.Error); + Assert.Null(result.Value); + } + + [Fact] + public void Failure_With_Null_Error_Should_Throw_ArgumentNullException() + { + Assert.Throws(() => Result.Failure(null)); + } + + [Fact] + public void Failure_With_Different_Error_Codes_Should_Create_Failure_Result() + { + var testCases = new[] + { + HttpStatusCode.NotFound, + HttpStatusCode.InternalServerError, + HttpStatusCode.Unauthorized, + HttpStatusCode.Forbidden + }; + + foreach (var statusCode in testCases) + { + var error = new ApiError(statusCode, "Error"); + + var result = Result.Failure(error); + + Assert.True(result.IsFailure); + Assert.Equal(error, result.Error); + Assert.Equal(statusCode, result.Error.StatusCode); + } + } + + [Fact] + public void Map_On_Success_Should_Transform_Value() + { + var result = Result.Success(5); + + var mapped = result.Map(x => x * 2); + + Assert.True(mapped.IsSuccess); + Assert.Equal(10, mapped.Value); + } + + [Fact] + public void Map_On_Success_Should_Change_Type() + { + var result = Result.Success(42); + + var mapped = result.Map(x => x.ToString()); + + Assert.True(mapped.IsSuccess); + Assert.Equal("42", mapped.Value); + } + + [Fact] + public void Map_On_Failure_Should_Propagate_Error() + { + var error = new ApiError(HttpStatusCode.BadRequest, "Original error"); + var result = Result.Failure(error); + + var mapped = result.Map(x => x * 2); + + Assert.False(mapped.IsSuccess); + Assert.True(mapped.IsFailure); + Assert.Equal(error, mapped.Error); + Assert.Equal(HttpStatusCode.BadRequest, mapped.Error.StatusCode); + Assert.Equal("Original error", mapped.Error.Message); + } + + [Fact] + public void Map_On_Failure_Should_Not_Execute_Mapper() + { + var error = new ApiError(HttpStatusCode.BadRequest, "Error"); + var result = Result.Failure(error); + var mapperExecuted = false; + + var mapped = result.Map(x => + { + mapperExecuted = true; + return x * 2; + }); + + Assert.False(mapperExecuted); + Assert.True(mapped.IsFailure); + } + + [Fact] + public void Map_Chain_On_Success_Should_Transform_Multiple_Times() + { + var result = Result.Success(5); + + var mapped = result + .Map(x => x * 2) + .Map(x => x + 10) + .Map(x => x.ToString()); + + Assert.True(mapped.IsSuccess); + Assert.Equal("20", mapped.Value); + } + + [Fact] + public void Map_Chain_On_Failure_Should_Propagate_Error_Through_Chain() + { + var error = new ApiError(HttpStatusCode.BadRequest, "Error"); + var result = Result.Failure(error); + + var mapped = result + .Map(x => x * 2) + .Map(x => x + 10) + .Map(x => x.ToString()); + + Assert.True(mapped.IsFailure); + Assert.Equal(error, mapped.Error); + } + + [Fact] + public void Match_On_Success_Should_Execute_OnSuccess() + { + var result = Result.Success(42); + + var output = result.Match( + onSuccess: value => $"Success: {value}", + onFailure: error => $"Failure: {error.Message}" + ); + + Assert.Equal("Success: 42", output); + } + + [Fact] + public void Match_On_Failure_Should_Execute_OnFailure() + { + var error = new ApiError(HttpStatusCode.BadRequest, "Bad request"); + var result = Result.Failure(error); + + var output = result.Match( + onSuccess: value => $"Success: {value}", + onFailure: err => $"Failure: {err.Message}" + ); + + Assert.Equal("Failure: Bad request", output); + } + + [Fact] + public void Match_On_Success_Should_Not_Execute_OnFailure() + { + var result = Result.Success(42); + var onFailureExecuted = false; + + result.Match( + onSuccess: value => value * 2, + onFailure: error => + { + onFailureExecuted = true; + return 0; + } + ); + + Assert.False(onFailureExecuted); + } + + [Fact] + public void Match_On_Failure_Should_Not_Execute_OnSuccess() + { + var error = new ApiError(HttpStatusCode.BadRequest, "Error"); + var result = Result.Failure(error); + var onSuccessExecuted = false; + + result.Match( + onSuccess: value => + { + onSuccessExecuted = true; + return value; + }, + onFailure: err => 0 + ); + + Assert.False(onSuccessExecuted); + } + + [Fact] + public void Match_Should_Return_Correct_Type() + { + var result = Result.Success("test"); + + var length = result.Match( + onSuccess: value => value.Length, + onFailure: error => -1 + ); + + Assert.Equal(4, length); + } + + [Fact] + public void Match_With_Different_Return_Types_Should_Work() + { + var successResult = Result.Success(42); + var failureResult = Result.Failure(new ApiError(HttpStatusCode.BadRequest, "Error")); + + var successOutput = successResult.Match( + onSuccess: value => (true, value), + onFailure: error => (false, 0) + ); + + var failureOutput = failureResult.Match( + onSuccess: value => (true, value), + onFailure: error => (false, 0) + ); + + Assert.True(successOutput.Item1); + Assert.Equal(42, successOutput.Item2); + Assert.False(failureOutput.Item1); + Assert.Equal(0, failureOutput.Item2); + } + + [Fact] + public void IsFailure_Should_Be_Opposite_Of_IsSuccess() + { + var success = Result.Success(42); + var failure = Result.Failure(new ApiError(HttpStatusCode.BadRequest, "Error")); + + Assert.True(success.IsSuccess); + Assert.False(success.IsFailure); + Assert.False(failure.IsSuccess); + Assert.True(failure.IsFailure); + } + + [Fact] + public void Multiple_Success_Results_Should_Be_Independent() + { + var result1 = Result.Success(1); + var result2 = Result.Success(2); + + Assert.Equal(1, result1.Value); + Assert.Equal(2, result2.Value); + Assert.NotEqual(result1.Value, result2.Value); + } + + [Fact] + public void Multiple_Failure_Results_Should_Be_Independent() + { + var error1 = new ApiError(HttpStatusCode.BadRequest, "Error 1"); + var error2 = new ApiError(HttpStatusCode.NotFound, "Error 2"); + var result1 = Result.Failure(error1); + var result2 = Result.Failure(error2); + + Assert.Equal(error1, result1.Error); + Assert.Equal(error2, result2.Error); + Assert.NotEqual(result1.Error, result2.Error); + } + } +} \ No newline at end of file diff --git a/CoreZipCode.Tests/Services/Interfaces/ApiHandlerTest.cs b/CoreZipCode.Tests/Services/Interfaces/ApiHandlerTest.cs deleted file mode 100644 index e405a85..0000000 --- a/CoreZipCode.Tests/Services/Interfaces/ApiHandlerTest.cs +++ /dev/null @@ -1,70 +0,0 @@ -using CoreZipCode.Interfaces; -using Moq; -using Moq.Protected; -using System; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Xunit; - -namespace CoreZipCode.Tests.Services.Interfaces -{ - public static class ApiHandlerTest - { - private const string SendAsync = "SendAsync"; - private const string MockUri = "https://unit.test.com/"; - - private static HttpClient ConfigureService(HttpStatusCode statusCode) - { - var handlerMock = new Mock(); - - handlerMock - .Protected() - .Setup>( - SendAsync, - ItExpr.IsAny(), - ItExpr.IsAny() - ) - .ReturnsAsync(new HttpResponseMessage - { - StatusCode = statusCode, - Content = new StringContent(""), - }) - .Verifiable(); - - var httpClient = new HttpClient(handlerMock.Object) - { - BaseAddress = new Uri(MockUri), - }; - - return httpClient; - } - - [Fact] - public static void MustCreateANewInstance() - { - var apiHandler = new ApiHandler(); - var apiHandlerWithHttpClient = new ApiHandler(ConfigureService(HttpStatusCode.OK)); - - Assert.IsType(apiHandler); - Assert.IsType(apiHandlerWithHttpClient); - } - - [Fact] - public static void MustCallApiException() - { - var apiHandler = new ApiHandler(ConfigureService(HttpStatusCode.BadRequest)); - - Assert.Throws(() => apiHandler.CallApi(MockUri)); - } - - [Fact] - public static async Task MustCallApiAsyncException() - { - var apiHandler = new ApiHandler(ConfigureService(HttpStatusCode.BadRequest)); - - await Assert.ThrowsAsync(() => apiHandler.CallApiAsync(MockUri)); - } - } -} \ No newline at end of file diff --git a/CoreZipCode.Tests/Services/Postcode/PostalpincodeInApi/PostalpincodeInTest.cs b/CoreZipCode.Tests/Services/Postcode/PostalpincodeInApi/PostalpincodeInTest.cs index d9e77e8..febe235 100644 --- a/CoreZipCode.Tests/Services/Postcode/PostalpincodeInApi/PostalpincodeInTest.cs +++ b/CoreZipCode.Tests/Services/Postcode/PostalpincodeInApi/PostalpincodeInTest.cs @@ -1,101 +1,145 @@ -using CoreZipCode.Services.Postcode.PostalpincodeInApi; -using Moq; -using Moq.Protected; -using System; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Xunit; - -namespace CoreZipCode.Tests.Services.Postcode.PostalpincodeInApi -{ - public class PostalpincodeInTest - { - private const string ExpectedResponse = "{\"Message\":\"Number of Post office(s) found: 21\",\"Status\":\"Success\",\"PostOffice\":[{\"Name\":\"Baroda House\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Bengali Market\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Bhagat Singh Market\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Connaught Place\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Constitution House\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Election Commission\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Janpath\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Krishi Bhawan\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Lady Harding Medical College\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"New Delhi \",\"Description\":\"\",\"BranchType\":\"Head Post Office\",\"DeliveryStatus\":\"Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"New Delhi\",\"Division\":\"New Delhi GPO\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"North Avenue\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Parliament House\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Patiala House\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Pragati Maidan\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Pragati Maidan Camp\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Rail Bhawan\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Sansad Marg\",\"Description\":\"\",\"BranchType\":\"Head Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Sansadiya Soudh\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Secretariat North\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Shastri Bhawan\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Supreme Court\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"}]}"; - private const string ExpectedStatusSuccess = "Success"; - private const string ExpectedPostOffice = "Baroda House"; - private const string ExpectedCountry = "India"; - private const string PostalPinCodeTest = "110001"; - private const string SendAsync = "SendAsync"; - private const string MockUri = "https://unit.test.com/"; - - private readonly PostalpincodeIn _service; - - public PostalpincodeInTest() - { - _service = ConfigureService(ExpectedResponse); - } - - private static PostalpincodeIn ConfigureService(string response) - { - var handlerMock = new Mock(); - - handlerMock - .Protected() - .Setup>( - SendAsync, - ItExpr.IsAny(), - ItExpr.IsAny() - ) - .ReturnsAsync(new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = new StringContent(response), - }) - .Verifiable(); - - var httpClient = new HttpClient(handlerMock.Object) - { - BaseAddress = new Uri(MockUri), - }; - - return new PostalpincodeIn(httpClient); +using CoreZipCode.Interfaces; +using CoreZipCode.Result; +using CoreZipCode.Services.Postcode.PostalpincodeInApi; +using CoreZipCode.Services.ZipCode.ViaCepApi; +using Moq; +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Xunit; + +namespace CoreZipCode.Tests.Services.Postcode.PostalpincodeInApi +{ + public class PostalpincodeInTest + { + private const string ValidPincode = "110001"; + private const string ExpectedJsonResponse = "{\"Message\":\"Number of Post office(s) found: 21\",\"Status\":\"Success\",\"PostOffice\":[{\"Name\":\"Baroda House\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Bengali Market\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Bhagat Singh Market\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Connaught Place\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Constitution House\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Election Commission\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Janpath\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Krishi Bhawan\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Lady Harding Medical College\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"New Delhi \",\"Description\":\"\",\"BranchType\":\"Head Post Office\",\"DeliveryStatus\":\"Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"New Delhi\",\"Division\":\"New Delhi GPO\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"North Avenue\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Parliament House\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Patiala House\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Pragati Maidan\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Pragati Maidan Camp\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Rail Bhawan\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Sansad Marg\",\"Description\":\"\",\"BranchType\":\"Head Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Sansadiya Soudh\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Secretariat North\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Shastri Bhawan\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"},{\"Name\":\"Supreme Court\",\"Description\":\"\",\"BranchType\":\"Sub Post Office\",\"DeliveryStatus\":\"Non-Delivery\",\"Taluk\":\"New Delhi\",\"Circle\":\"New Delhi\",\"District\":\"Central Delhi\",\"Division\":\"New Delhi Central\",\"Region\":\"Delhi\",\"State\":\"Delhi\",\"Country\":\"India\"}]}"; + + private readonly PostalpincodeIn _service; + private readonly Mock _apiHandlerMock; + + public PostalpincodeInTest() + { + _apiHandlerMock = new Mock(); + _service = new PostalpincodeIn(_apiHandlerMock.Object); + } + + [Fact] + public void Constructor_Should_Create_Instance_Without_Parameters() + { + var service = new PostalpincodeIn(); + Assert.NotNull(service); + } + + [Fact] + public void Constructor_With_Null_ApiHandler_Should_Throw_ArgumentNullException() + { + Assert.Throws(() => new PostalpincodeIn((IApiHandler)null)); + } + + [Fact] + public void Constructor_Should_Create_Instance_With_HttpClient() + { + new PostalpincodeIn(new HttpClient()); + } + + [Fact] + public async Task GetPostcodeAsync_With_Valid_Pincode_Should_Return_Success_Result() + { + _apiHandlerMock + .Setup(x => x.CallApiAsync($"http://postalpincode.in/api/pincode/{ValidPincode}")) + .ReturnsAsync(Result.Success(ExpectedJsonResponse)); + + var result = await _service.GetPostcodeAsync(ValidPincode); + + Assert.True(result.IsSuccess); + Assert.NotNull(result.Value); + Assert.Equal("Success", result.Value.Status); + Assert.Equal("Baroda House", result.Value.PostOffice[0].Name); + Assert.Equal("India", result.Value.PostOffice[0].Country); + Assert.Equal(21, result.Value.PostOffice.Count); + } + + [Fact] + public async Task GetPostcodeAsync_With_Invalid_Pincode_Should_Return_Failure_From_ApiHandler() + { + var error = new ApiError(HttpStatusCode.NotFound, "Not Found", responseBody: "No records found"); + _apiHandlerMock + .Setup(x => x.CallApiAsync(It.IsAny())) + .ReturnsAsync(Result.Failure(error)); + + var result = await _service.GetPostcodeAsync("000000"); + + Assert.True(result.IsFailure); + Assert.Equal(HttpStatusCode.NotFound, result.Error.StatusCode); + Assert.Contains("Not Found", result.Error.Message); + } + + [Fact] + public async Task GetPostcodeAsync_With_Invalid_Pincode_Should_Return_Failure_From_EmptyResponse() + { + _apiHandlerMock + .Setup(x => x.CallApiAsync(It.IsAny())) + .ReturnsAsync(Result.Success("")); + + var result = await _service.GetPostcodeAsync("000000"); + + Assert.True(result.IsFailure); + Assert.Equal(HttpStatusCode.NotFound, result.Error.StatusCode); + Assert.Contains("Postcode not found or empty response.", result.Error.Message); + } + + [Fact] + public async Task GetPostcodeAsync_With_Network_Error_Should_Propagate_Failure() + { + var networkError = new ApiError(HttpStatusCode.ServiceUnavailable, "Network error"); + _apiHandlerMock + .Setup(x => x.CallApiAsync(It.IsAny())) + .ReturnsAsync(Result.Failure(networkError)); + + var result = await _service.GetPostcodeAsync(ValidPincode); + + Assert.True(result.IsFailure); + Assert.Equal(HttpStatusCode.ServiceUnavailable, result.Error.StatusCode); + } + + [Fact] + public async Task GetPostcodeAsync_With_Malformed_Json_Should_Return_Failure_With_UnprocessableEntity() + { + _apiHandlerMock + .Setup(x => x.CallApiAsync(It.IsAny())) + .ReturnsAsync(Result.Success("{{{ invalid json }}}")); + + var result = await _service.GetPostcodeAsync(ValidPincode); + + Assert.True(result.IsFailure); + Assert.Equal(HttpStatusCode.UnprocessableEntity, result.Error.StatusCode); + Assert.Contains("Failed to parse", result.Error.Message); } - [Fact] - public static void ConstructorTest() - { - var actual = new PostalpincodeIn(); - Assert.NotNull(actual); - } - - [Fact] - public void MustGetPostcodes() - { - var actual = _service.Execute(PostalPinCodeTest); - - Assert.Equal(ExpectedResponse, actual); - } - - [Fact] - public void MustGetPostcodesObject() - { - var actual = _service.GetPostcode(PostalPinCodeTest); - - Assert.IsType(actual); - Assert.Equal(ExpectedStatusSuccess, actual.Status); - Assert.Equal(ExpectedPostOffice, actual.PostOffice[0].Name); - Assert.Equal(ExpectedCountry, actual.PostOffice[0].Country); - } - - [Fact] - public async Task MustGetPostcodesAsync() - { - var actual = await _service.ExecuteAsync(PostalPinCodeTest); - - Assert.Equal(ExpectedResponse, actual); - } - - [Fact] - public async Task MustGetPostcodesObjectAsync() - { - var actual = await _service.GetPostcodeAsync(PostalPinCodeTest); - - Assert.IsType(actual); - Assert.Equal(ExpectedStatusSuccess, actual.Status); - Assert.Equal(ExpectedPostOffice, actual.PostOffice[0].Name); - Assert.Equal(ExpectedCountry, actual.PostOffice[0].Country); - } - } -} \ No newline at end of file + [Fact] + public void SetPostcodeUrl_Should_Generate_Correct_Url() + { + var service = new PostalpincodeIn(); + + var url = service.SetPostcodeUrl("400001"); + + Assert.Equal("http://postalpincode.in/api/pincode/400001", url); + } + + [Fact] + [Obsolete("Will be removed in next version")] + public void Execute_Always_Throws_NotSupportedException() + { + Assert.Throws(() => _service.Execute("400001")); + } + + [Fact] + [Obsolete("Will be removed in next version")] + public void GetAddress_Always_Throws_NotSupportedException() + { + Assert.Throws(() => _service.GetPostcode("400001")); + } + } +} diff --git a/CoreZipCode.Tests/Services/Postcode/PostcodesIoApi/PostcodesIoTest.cs b/CoreZipCode.Tests/Services/Postcode/PostcodesIoApi/PostcodesIoTest.cs index 1837724..d801ab7 100644 --- a/CoreZipCode.Tests/Services/Postcode/PostcodesIoApi/PostcodesIoTest.cs +++ b/CoreZipCode.Tests/Services/Postcode/PostcodesIoApi/PostcodesIoTest.cs @@ -1,10 +1,10 @@ +using CoreZipCode.Interfaces; +using CoreZipCode.Result; using CoreZipCode.Services.Postcode.PostcodesIoApi; +using CoreZipCode.Services.ZipCode.SmartyApi; using Moq; -using Moq.Protected; -using System; using System.Net; using System.Net.Http; -using System.Threading; using System.Threading.Tasks; using Xunit; @@ -12,90 +12,81 @@ namespace CoreZipCode.Tests.Services.Postcode.PostcodesIoApi { public class PostcodesIoTest { - private const string ExpectedResponse = "{\"status\":200,\"result\":[{\"postcode\":\"OX49 5NU\",\"quality\":1,\"eastings\":464447,\"northings\":195647,\"country\":\"England\",\"nhs_ha\":\"South Central\",\"longitude\":-1.069752,\"latitude\":51.655929,\"european_electoral_region\":\"South East\",\"primary_care_trust\":\"Oxfordshire\",\"region\":\"South East\",\"lsoa\":\"South Oxfordshire 011B\",\"msoa\":\"South Oxfordshire 011\",\"incode\":\"5NU\",\"outcode\":\"OX49\",\"parliamentary_constituency\":\"Henley\",\"admin_district\":\"South Oxfordshire\",\"parish\":\"Brightwell Baldwin\",\"admin_county\":\"Oxfordshire\",\"admin_ward\":\"Chalgrove\",\"ced\":\"Chalgrove and Watlington\",\"ccg\":\"NHS Oxfordshire\",\"nuts\":\"Oxfordshire\",\"codes\":{\"admin_district\":\"E07000179\",\"admin_county\":\"E10000025\",\"admin_ward\":\"E05009735\",\"parish\":\"E04008109\",\"parliamentary_constituency\":\"E14000742\",\"ccg\":\"E38000136\",\"ced\":\"E58001238\",\"nuts\":\"UKJ14\"}}]}"; - private const string ExpectedCountry = "England"; - private const string PostalPinCodeTest = "OX49 5NU"; - private const string SendAsync = "SendAsync"; - private const string MockUri = "https://unit.test.com/"; - private const int ExpectedStatusSuccess = 200; - private const int ExpectedResultQuality = 1; + private const string ValidPostcode = "OX49 5NU"; + private const string SuccessJson = """{"status":200,"result":[{"postcode":"OX49 5NU","quality":1,"eastings":464447,"northings":195647,"country":"England","nhs_ha":"South Central","longitude":-1.069752,"latitude":51.655929,"european_electoral_region":"South East","primary_care_trust":"Oxfordshire","region":"South East","lsoa":"South Oxfordshire 011B","msoa":"South Oxfordshire 011","incode":"5NU","outcode":"OX49","parliamentary_constituency":"Henley","admin_district":"South Oxfordshire","parish":"Brightwell Baldwin","admin_county":"Oxfordshire","admin_ward":"Chalgrove","ced":"Chalgrove and Watlington","ccg":"NHS Oxfordshire","nuts":"Oxfordshire","codes":{"admin_district":"E07000179","admin_county":"E10000025","admin_ward":"E05009735","parish":"E04008109","parliamentary_constituency":"E14000742","ccg":"E38000136","ced":"E58001238","nuts":"UKJ14"}}]}"""; + + private readonly Mock _apiHandlerMock; private readonly PostcodesIo _service; public PostcodesIoTest() { - _service = ConfigureService(ExpectedResponse); + _apiHandlerMock = new Mock(); + _service = new PostcodesIo(_apiHandlerMock.Object); } - private static PostcodesIo ConfigureService(string response) + [Fact] + public void Constructor_Should_Create_Instance() { - var handlerMock = new Mock(); - - handlerMock - .Protected() - .Setup>( - SendAsync, - ItExpr.IsAny(), - ItExpr.IsAny() - ) - .ReturnsAsync(new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = new StringContent(response), - }) - .Verifiable(); - - var httpClient = new HttpClient(handlerMock.Object) - { - BaseAddress = new Uri(MockUri), - }; - - return new PostcodesIo(httpClient); + new PostcodesIo(); } [Fact] - public static void ConstructorTest() + public void Constructor_Should_Create_Instance_With_HttpClient() { - var actual = new PostcodesIo(); - Assert.NotNull(actual); + new PostcodesIo(new HttpClient()); } [Fact] - public void MustGetPostcodes() + public async Task GetPostcodeAsync_ValidPostcode_Returns_Success_Result() { - var actual = _service.Execute(PostalPinCodeTest); + _apiHandlerMock + .Setup(x => x.CallApiAsync(It.IsAny())) + .ReturnsAsync(Result.Success(SuccessJson)); + + var result = await _service.GetPostcodeAsync(ValidPostcode); - Assert.Equal(ExpectedResponse, actual); + Assert.True(result.IsSuccess); + Assert.NotNull(result.Value); + Assert.Equal(200, result.Value.Status); + Assert.Equal("England", result.Value.Result[0].Country); + Assert.Equal(1, result.Value.Result[0].Quality); } [Fact] - public void MustGetPostcodesObject() + public async Task GetPostcodeAsync_ApiReturnsNotFound_Returns_Failure() { - var actual = _service.GetPostcode(PostalPinCodeTest); + var error = new ApiError(HttpStatusCode.NotFound, "Not found"); + _apiHandlerMock + .Setup(x => x.CallApiAsync(It.IsAny())) + .ReturnsAsync(Result.Failure(error)); - Assert.IsType(actual); - Assert.Equal(ExpectedStatusSuccess, actual.Status); - Assert.Equal(ExpectedResultQuality, actual.Result[0].Quality); - Assert.Equal(ExpectedCountry, actual.Result[0].Country); + var result = await _service.GetPostcodeAsync("INVALID"); + + Assert.True(result.IsFailure); + Assert.Equal(HttpStatusCode.NotFound, result.Error.StatusCode); } [Fact] - public async Task MustGetPostcodesAsync() + public async Task GetPostcodeAsync_ApiReturnsMalformedJson_Returns_UnprocessableEntity() { - var actual = await _service.ExecuteAsync(PostalPinCodeTest); + _apiHandlerMock + .Setup(x => x.CallApiAsync(It.IsAny())) + .ReturnsAsync(Result.Success("{ invalid json }")); + + var result = await _service.GetPostcodeAsync(ValidPostcode); - Assert.Equal(ExpectedResponse, actual); + Assert.True(result.IsFailure); + Assert.Equal(HttpStatusCode.UnprocessableEntity, result.Error.StatusCode); } [Fact] - public async Task MustGetPostcodesObjectAsync() + public void SetPostcodeUrl_Should_Generate_Correct_Url() { - var actual = await _service.GetPostcodeAsync(PostalPinCodeTest); + var service = new PostcodesIo(); + var url = service.SetPostcodeUrl("SW1A 1AA"); - Assert.IsType(actual); - Assert.Equal(ExpectedStatusSuccess, actual.Status); - Assert.Equal(ExpectedResultQuality, actual.Result[0].Quality); - Assert.Equal(ExpectedCountry, actual.Result[0].Country); + Assert.Equal("https://api.postcodes.io/postcodes?q=SW1A%201AA", url); } } -} \ No newline at end of file +} diff --git a/CoreZipCode.Tests/Services/ZipCode/SmartyApi/SmartyTest.cs b/CoreZipCode.Tests/Services/ZipCode/SmartyApi/SmartyTest.cs index 6475feb..96ac859 100644 --- a/CoreZipCode.Tests/Services/ZipCode/SmartyApi/SmartyTest.cs +++ b/CoreZipCode.Tests/Services/ZipCode/SmartyApi/SmartyTest.cs @@ -1,212 +1,160 @@ -using CoreZipCode.Services.ZipCode.SmartyApi; -using Moq; -using Moq.Protected; -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Xunit; - -namespace CoreZipCode.Tests.Services.ZipCode.SmartyApi -{ - public class SmartyTest - { - private const string ExpectedZipcodeResponse = "[{\"input_index\":0,\"city_states\":[{\"city\":\"Cupertino\",\"state_abbreviation\":\"CA\",\"state\":\"California\",\"mailable_city\":true},{\"city\":\"Monte Vista\",\"state_abbreviation\":\"CA\",\"state\":\"California\",\"mailable_city\":true},{\"city\":\"Permanente\",\"state_abbreviation\":\"CA\",\"state\":\"California\",\"mailable_city\":true}],\"zipcodes\":[{\"zipcode\":\"95014\",\"zipcode_type\":\"S\",\"default_city\":\"Cupertino\",\"county_fips\":\"06085\",\"county_name\":\"Santa Clara\",\"state_abbreviation\":\"CA\",\"state\":\"California\",\"latitude\":37.32098,\"longitude\":-122.03838,\"precision\":\"Zip5\"}]}]"; - private const string ExpectedParamResponse = "[{\"input_index\":0,\"candidate_index\":0,\"delivery_line_1\":\"1600 Amphitheatre Pkwy\",\"last_line\":\"Mountain View CA 94043-1351\",\"delivery_point_barcode\":\"940431351000\",\"components\":{\"primary_number\":\"1600\",\"street_name\":\"Amphitheatre\",\"street_suffix\":\"Pkwy\",\"city_name\":\"Mountain View\",\"default_city_name\":\"Mountain View\",\"state_abbreviation\":\"CA\",\"zipcode\":\"94043\",\"plus4_code\":\"1351\",\"delivery_point\":\"00\",\"delivery_point_check_digit\":\"0\"},\"metadata\":{\"record_type\":\"S\",\"zip_type\":\"Standard\",\"county_fips\":\"06085\",\"county_name\":\"Santa Clara\",\"carrier_route\":\"C909\",\"congressional_district\":\"18\",\"rdi\":\"Commercial\",\"elot_sequence\":\"0094\",\"elot_sort\":\"A\",\"latitude\":37.42357,\"longitude\":-122.08661,\"precision\":\"Zip9\",\"time_zone\":\"Pacific\",\"utc_offset\":-8,\"dst\":true},\"analysis\":{\"dpv_match_code\":\"Y\",\"dpv_footnotes\":\"AABB\",\"dpv_cmra\":\"N\",\"dpv_vacant\":\"N\",\"active\":\"N\"}}]"; - private const string ExpectedState = "CA"; - private const string ExpectedCity = "Cupertino"; - private const string ZipCodeTest = "95014"; - private const string SendAsync = "SendAsync"; - private const string MockUri = "https://unit.test.com/"; - private const string SmartyParameterState = "CA"; - private const string SmartyParameterCity = "Mountain View"; - private const string SmartyParameterStreet = "1600 Amphitheatre Pkwy"; - private const string InvalidStreetMessage = "Invalid Street, parameter over size of 64 characters."; - private const string InvalidCityMessage = "Invalid City, parameter over size of 64 characters."; - private const string InvalidStateMessage = "Invalid State, parameter over size of 32 characters."; - private const string InvalidZipCodeFormatMessage = "Invalid ZipCode Format"; - private const string InvalidZipCodeSizeMessage = "Invalid ZipCode Size"; - private const string AuthToken = "some auth token"; - private const string AuthId = "some auth id"; - - private readonly Smarty _service; - private readonly Smarty _serviceParam; - - public SmartyTest() - { - _service = ConfigureService(ExpectedZipcodeResponse); - _serviceParam = ConfigureService(ExpectedParamResponse); - } - - private static Smarty ConfigureService(string response) - { - var handlerMock = new Mock(); - - handlerMock - .Protected() - .Setup>( - SendAsync, - ItExpr.IsAny(), - ItExpr.IsAny() - ) - .ReturnsAsync(new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = new StringContent(response), - }) - .Verifiable(); - - var httpClient = new HttpClient(handlerMock.Object) - { - BaseAddress = new Uri(MockUri) - }; - - return new Smarty(httpClient, "test-auth-id", "test-auth-token"); - } - - [Fact] - public static void ConstructorTest() - { - var actual = new Smarty(AuthId, AuthToken); - Assert.NotNull(actual); - } - - [Fact] - public static void ConstructorNullTest() - { - Assert.Throws(() => new Smarty(new HttpClient(), AuthId, null)); - Assert.Throws(() => new Smarty(new HttpClient(), null, AuthToken)); - Assert.Throws(() => new Smarty(new HttpClient(), null, null)); - Assert.Throws(() => new Smarty(new HttpClient(), AuthId, string.Empty)); - Assert.Throws(() => new Smarty(new HttpClient(), string.Empty, AuthToken)); - Assert.Throws(() => new Smarty(new HttpClient(), string.Empty, string.Empty)); - - Assert.Throws(() => new Smarty(AuthId, null)); - Assert.Throws(() => new Smarty(null, AuthToken)); - Assert.Throws(() => new Smarty(null, null)); - Assert.Throws(() => new Smarty(AuthId, string.Empty)); - Assert.Throws(() => new Smarty(string.Empty, AuthToken)); - Assert.Throws(() => new Smarty(string.Empty, string.Empty)); - } - - [Fact] - public void MustGetSingleZipCodeJsonString() - { - var actual = _service.Execute(ZipCodeTest); - - Assert.Equal(ExpectedZipcodeResponse, actual); - } - - [Fact] - public void MustGetZipCodeByParamsJsonString() - { - var actual = _serviceParam.Execute(SmartyParameterState, SmartyParameterCity, SmartyParameterStreet); - - Assert.Equal(ExpectedParamResponse, actual); - } - - [Fact] - public void MustGetSingleZipCodeObject() - { - var actual = _service.GetAddress>(ZipCodeTest); - - Assert.IsType>(actual); - Assert.Equal(ExpectedCity, actual[0].CityStates[0].City); - Assert.Equal(ExpectedState, actual[0].CityStates[0].StateAbbreviation); - } - - [Fact] - public void MustGetZipCodeByParamsList() - { - var actual = _serviceParam.ListAddresses(SmartyParameterState, SmartyParameterCity, SmartyParameterStreet); - - Assert.IsType>(actual); - Assert.True(actual.Count > 0); - Assert.Equal(SmartyParameterCity, actual[0].Components.CityName); - Assert.Equal(SmartyParameterState, actual[0].Components.StateAbbreviation); - } - - [Fact] - public void MustThrowTheExceptions() - { - var exception = Assert.Throws(() => _service.Execute(" 12345678901234567890 ")); - Assert.Equal(InvalidZipCodeSizeMessage, exception.Message); - - exception = Assert.Throws(() => _service.Execute(" 12A")); - Assert.Equal(InvalidZipCodeSizeMessage, exception.Message); - - exception = Assert.Throws(() => _service.Execute(" 123A5678 ")); - Assert.Equal(InvalidZipCodeFormatMessage, exception.Message); - - exception = Assert.Throws(() => _service.Execute("Lorem ipsum dolor sit amet amet sit", "Mountain View", "1600 Amphitheatre Pkwy")); - Assert.Equal(InvalidStateMessage, exception.Message); - - exception = Assert.Throws(() => _service.Execute("CA", "Lorem ipsum dolor sit amet, consectetur adipiscing elit posuere posuere.", "1600 Amphitheatre Pkwy")); - Assert.Equal(InvalidCityMessage, exception.Message); - - exception = Assert.Throws(() => _service.Execute("CA", "Mountain View", "Lorem ipsum dolor sit amet, consectetur adipiscing elit posuere posuere.")); - Assert.Equal(InvalidStreetMessage, exception.Message); - } - - [Fact] - public async Task MustGetSingleZipCodeJsonStringAsync() - { - var actual = await _service.ExecuteAsync(ZipCodeTest); - - Assert.Equal(ExpectedZipcodeResponse, actual); - } - - [Fact] - public async Task MustGetListZipCodeJsonStringAsync() - { - var actual = await _serviceParam.ExecuteAsync(SmartyParameterState, SmartyParameterCity, SmartyParameterStreet); - - Assert.Equal(ExpectedParamResponse, actual); - } - - [Fact] - public async Task MustGetSingleZipCodeObjectAsync() - { - var actual = await _service.GetAddressAsync>(ZipCodeTest); - - Assert.IsType>(actual); - Assert.Equal(ExpectedCity, actual[0].CityStates[0].City); - Assert.Equal(ExpectedState, actual[0].CityStates[0].StateAbbreviation); - } - - [Fact] - public async Task MustGetZipCodeByParamsListAsync() - { - var actual = await _serviceParam.ListAddressesAsync(SmartyParameterState, SmartyParameterCity, SmartyParameterStreet); - - Assert.IsType>(actual); - Assert.True(actual.Count > 0); - Assert.Equal(SmartyParameterCity, actual[0].Components.CityName); - Assert.Equal(SmartyParameterState, actual[0].Components.StateAbbreviation); - } - - [Fact] - public async Task MustThrowTheExceptionsAsync() - { - var exception = await Assert.ThrowsAsync(() => _service.ExecuteAsync(" 12345678901234567890 ")); - Assert.Equal(InvalidZipCodeSizeMessage, exception.Message); - - exception = await Assert.ThrowsAsync(() => _service.ExecuteAsync(" 12A")); - Assert.Equal(InvalidZipCodeSizeMessage, exception.Message); - - exception = await Assert.ThrowsAsync(() => _service.ExecuteAsync(" 123A5678 ")); - Assert.Equal(InvalidZipCodeFormatMessage, exception.Message); - - exception = await Assert.ThrowsAsync(() => _service.ExecuteAsync("Lorem ipsum dolor sit amet amet sit", "Mountain View", "1600 Amphitheatre Pkwy")); - Assert.Equal(InvalidStateMessage, exception.Message); - - exception = await Assert.ThrowsAsync(() => _service.ExecuteAsync("CA", "Lorem ipsum dolor sit amet, consectetur adipiscing elit posuere posuere.", "1600 Amphitheatre Pkwy")); - Assert.Equal(InvalidCityMessage, exception.Message); - - exception = await Assert.ThrowsAsync(() => _service.ExecuteAsync("CA", "Mountain View", "Lorem ipsum dolor sit amet, consectetur adipiscing elit posuere posuere.")); - Assert.Equal(InvalidStreetMessage, exception.Message); - } - } -} \ No newline at end of file +using CoreZipCode.Interfaces; +using CoreZipCode.Result; +using CoreZipCode.Services.ZipCode.SmartyApi; +using CoreZipCode.Services.ZipCode.ViaCepApi; +using Moq; +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Xunit; + +namespace CoreZipCode.Tests.Services.ZipCode.SmartyApi +{ + public class SmartyTest + { + private const string AuthId = "test-id"; + private const string AuthToken = "test-token"; + private const string ZipcodeResponse = "[{\"input_index\":0,\"city_states\":[{\"city\":\"Cupertino\",\"state_abbreviation\":\"CA\",\"state\":\"California\",\"mailable_city\":true},{\"city\":\"Monte Vista\",\"state_abbreviation\":\"CA\",\"state\":\"California\",\"mailable_city\":true},{\"city\":\"Permanente\",\"state_abbreviation\":\"CA\",\"state\":\"California\",\"mailable_city\":true}],\"zipcodes\":[{\"zipcode\":\"95014\",\"zipcode_type\":\"S\",\"default_city\":\"Cupertino\",\"county_fips\":\"06085\",\"county_name\":\"Santa Clara\",\"state_abbreviation\":\"CA\",\"state\":\"California\",\"latitude\":37.32098,\"longitude\":-122.03838,\"precision\":\"Zip5\"}]}]"; + private const string StreetResponse = "[{\"input_index\":0,\"candidate_index\":0,\"delivery_line_1\":\"1600 Amphitheatre Pkwy\",\"last_line\":\"Mountain View CA 94043-1351\",\"delivery_point_barcode\":\"940431351000\",\"components\":{\"primary_number\":\"1600\",\"street_name\":\"Amphitheatre\",\"street_suffix\":\"Pkwy\",\"city_name\":\"Mountain View\",\"default_city_name\":\"Mountain View\",\"state_abbreviation\":\"CA\",\"zipcode\":\"94043\",\"plus4_code\":\"1351\",\"delivery_point\":\"00\",\"delivery_point_check_digit\":\"0\"},\"metadata\":{\"record_type\":\"S\",\"zip_type\":\"Standard\",\"county_fips\":\"06085\",\"county_name\":\"Santa Clara\",\"carrier_route\":\"C909\",\"congressional_district\":\"18\",\"rdi\":\"Commercial\",\"elot_sequence\":\"0094\",\"elot_sort\":\"A\",\"latitude\":37.42357,\"longitude\":-122.08661,\"precision\":\"Zip9\",\"time_zone\":\"Pacific\",\"utc_offset\":-8,\"dst\":true},\"analysis\":{\"dpv_match_code\":\"Y\",\"dpv_footnotes\":\"AABB\",\"dpv_cmra\":\"N\",\"dpv_vacant\":\"N\",\"active\":\"N\"}}]"; + + private readonly Mock _handlerMock = new(); + private readonly Smarty _service; + + public SmartyTest() + { + _service = new Smarty(_handlerMock.Object, AuthId, AuthToken); + } + + [Fact] + public void Constructor_Creates_Instance_With_HttpClient() + { + new Smarty(new HttpClient(), AuthId, AuthToken); + } + + [Fact] + public void Constructor_Requires_NonEmpty_Credentials() + { + Assert.Throws(() => new Smarty(null, AuthToken)); + Assert.Throws(() => new Smarty(AuthId, null)); + Assert.Throws(() => new Smarty("", AuthToken)); + Assert.Throws(() => new Smarty(AuthId, "")); + } + + [Fact] + public async Task GetAddressAsync_ValidZip_Returns_Success_With_ListOfSmartyModel() + { + _handlerMock + .Setup(x => x.CallApiAsync(It.Is(u => u.Contains("zipcode=95014")))) + .ReturnsAsync(Result.Success(ZipcodeResponse)); + + var result = await _service.GetAddressAsync>("95014"); + + Assert.True(result.IsSuccess); + Assert.Single(result.Value); + Assert.Equal("Cupertino", result.Value[0].CityStates[0].City); + Assert.Equal("CA", result.Value[0].CityStates[0].StateAbbreviation); + } + + [Fact] + public async Task ListAddressesAsync_ValidAddress_Returns_Success_With_StreetModel() + { + _handlerMock + .Setup(x => x.CallApiAsync(It.IsAny())) + .ReturnsAsync(Result.Success(StreetResponse)); + + var result = await _service.ListAddressesAsync("CA", "Mountain View", "1600 Amphitheatre Pkwy"); + + Assert.True(result.IsSuccess); + Assert.Single(result.Value); + Assert.Equal("Mountain View", result.Value[0].Components.CityName); + Assert.Equal("CA", result.Value[0].Components.StateAbbreviation); + } + + [Fact] + public async Task GetAddressAsync_ApiReturns404_Returns_Failure() + { + _handlerMock + .Setup(x => x.CallApiAsync(It.IsAny())) + .ReturnsAsync(Result.Failure(new ApiError(HttpStatusCode.NotFound, "Not found"))); + + var result = await _service.GetAddressAsync>("00000"); + + Assert.True(result.IsFailure); + Assert.Equal(HttpStatusCode.NotFound, result.Error.StatusCode); + } + + [Fact] + public async Task ListAddressesAsync_InvalidState_Throws_SmartyException() + { + var longState = new string('A', 33); + var ex = await Assert.ThrowsAsync( + () => _service.ListAddressesAsync(longState, "City", "Street")); + + Assert.Contains("exceeds 32 characters", ex.Message); + } + + [Fact] + public async Task GetAddressAsync_InvalidZipFormat_Throws_SmartyException() + { + var ex = await Assert.ThrowsAsync( + () => _service.GetAddressAsync>("12345a")); + + Assert.Contains("Invalid ZipCode Format", ex.Message); + } + + [Fact] + public async Task GetAddressAsync_InvalidZipSize_Throws_SmartyException() + { + var ex = await Assert.ThrowsAsync( + () => _service.GetAddressAsync>("ABC")); + + Assert.Contains("Invalid ZipCode Size", ex.Message); + } + + [Fact] + public async Task ListAddressesAsync_InvalidCity_Throws_SmartyException() + { + var longCity = new string('A', 65); + var ex = await Assert.ThrowsAsync( + () => _service.ListAddressesAsync("CA", longCity, "Street")); + + Assert.Contains("exceeds 64 characters", ex.Message); + } + + [Fact] + public async Task ListAddressesAsync_InvalidStreet_Throws_SmartyException() + { + var longStreet = new string('A', 65); + var ex = await Assert.ThrowsAsync( + () => _service.ListAddressesAsync("CA", "City", longStreet)); + + Assert.Contains("exceeds 64 characters", ex.Message); + } + + [Fact] + public void SetZipCodeUrl_Generates_Correct_Url() + { + var service = new Smarty(AuthId, AuthToken); + var url = service.SetZipCodeUrl("95014"); + + Assert.Contains("us-zipcode.api.smartystreets.com", url); + Assert.Contains("auth-id=test-id", url); + Assert.Contains("auth-token=test-token", url); + Assert.Contains("zipcode=95014", url); + } + + [Fact] + public void SetZipCodeUrlBy_Generates_Correct_Url_With_Encoded_Parameters() + { + var service = new Smarty(AuthId, AuthToken); + var url = service.SetZipCodeUrlBy("CA", "San José", "Main St"); + + Assert.Contains("us-street.api.smartystreets.com", url); + Assert.Contains("state=CA", url); + Assert.Contains("city=San%20Jos%C3%A9", url); + Assert.Contains("street=Main%20St", url); + } + } +} diff --git a/CoreZipCode.Tests/Services/ZipCode/ViaCepApi/ViaCepTest.cs b/CoreZipCode.Tests/Services/ZipCode/ViaCepApi/ViaCepTest.cs index d31c0bc..59d6edf 100644 --- a/CoreZipCode.Tests/Services/ZipCode/ViaCepApi/ViaCepTest.cs +++ b/CoreZipCode.Tests/Services/ZipCode/ViaCepApi/ViaCepTest.cs @@ -1,204 +1,325 @@ -using CoreZipCode.Services.ZipCode.ViaCepApi; -using Moq; -using Moq.Protected; -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; -using Xunit; - -namespace CoreZipCode.Tests.Services.ZipCode.ViaCepApi -{ - public class ViaCepTest - { - private const string ExpectedResponse = "{\n \"cep\": \"14810-100\",\n \"logradouro\": \"Rua Barão do Rio Branco\",\n \"complemento\": \"\",\n \"bairro\": \"Vila Xavier (Vila Xavier)\",\n \"localidade\": \"Araraquara\",\n \"uf\": \"SP\",\n \"ibge\": \"3503208\",\n \"gia\": \"1818\",\n \"ddd\": \"16\",\n \"siafi\": \"7107\"\n}"; - private const string ExpectedListResponse = "[\n {\n \"cep\": \"14810-100\",\n \"logradouro\": \"Rua Barão do Rio Branco\",\n \"complemento\": \"\",\n \"bairro\": \"Vila Xavier (Vila Xavier)\",\n \"localidade\": \"Araraquara\",\n \"uf\": \"SP\",\n \"ibge\": \"3503208\",\n \"gia\": \"1818\",\n \"ddd\": \"16\",\n \"siafi\": \"7107\" }\n]"; - private const string ExpectedState = "SP"; - private const string ExpectedCity = "Araraquara"; - private const string ZipCodeTest = "14810-100"; - private const string SendAsync = "SendAsync"; - private const string MockUri = "https://unit.test.com/"; - private const string ViaCepParameterState = "sp"; - private const string ViaCepParameterCity = "araraquara"; - private const string ViaCepParameterStreet = "barão do rio"; - private const string InvalidStreetMessage = "Invalid Street, parameter below size of 3 characters."; - private const string InvalidCityMessage = "Invalid City, parameter below size of 3 characters."; - private const string InvalidStateMessage = "Invalid State, parameter below size of 2 characters."; - private const string InvalidZipCodeFormatMessage = "Invalid ZipCode Format"; - private const string InvalidZipCodeSizeMessage = "Invalid ZipCode Size"; - - private readonly ViaCep _service; - private readonly ViaCep _serviceList; - - public ViaCepTest() - { - _service = ConfigureService(ExpectedResponse); - _serviceList = ConfigureService(ExpectedListResponse); - } - - private static ViaCep ConfigureService(string response) - { - var handlerMock = new Mock(); - - handlerMock - .Protected() - .Setup>( - SendAsync, - ItExpr.IsAny(), - ItExpr.IsAny() - ) - .ReturnsAsync(new HttpResponseMessage - { - StatusCode = HttpStatusCode.OK, - Content = new StringContent(response), - }) - .Verifiable(); - - var httpClient = new HttpClient(handlerMock.Object) - { - BaseAddress = new Uri(MockUri) - }; - - return new ViaCep(httpClient); - } - - [Fact] - public static void ConstructorTest() - { - var actual = new ViaCep(); - Assert.NotNull(actual); - } - - [Fact] - public void MustGetSingleZipCodeJsonString() - { - var actual = _service.Execute(ZipCodeTest); - - Assert.Equal(ExpectedResponse, actual); - } - - [Fact] - public void MustGetListZipCodeJsonString() - { - var actual = _serviceList.Execute(ViaCepParameterState, ViaCepParameterCity, ViaCepParameterStreet); - - Assert.Equal(ExpectedListResponse, actual); - } - - [Fact] - public void MustGetSingleZipCodeObject() - { - var actual = _service.GetAddress(ZipCodeTest); - - Assert.IsType(actual); - Assert.Equal(ExpectedCity, actual.City); - Assert.Equal(ExpectedState, actual.State); - } - - [Fact] - public void MustGetZipCodeObjectList() - { - var actual = _serviceList.ListAddresses(ViaCepParameterState, ViaCepParameterCity, ViaCepParameterStreet); - - Assert.IsType>(actual); - Assert.True(actual.Count > 0); - Assert.Equal(ExpectedCity, actual[0].City); - Assert.Equal(ExpectedState, actual[0].State); - } - - [Fact] - public void MustThrowTheExceptions() - { - var exception = Assert.Throws(() => _service.Execute(" 12345-67 ")); - Assert.Equal(InvalidZipCodeSizeMessage, exception.Message); - - exception = Assert.Throws(() => _service.Execute(" 123A5-678 ")); - Assert.Equal(InvalidZipCodeFormatMessage, exception.Message); - - exception = Assert.Throws(() => _service.Execute("U", "Araraquara", "barão do rio")); - Assert.Equal(InvalidStateMessage, exception.Message); - - exception = Assert.Throws(() => _service.Execute("SP", "Ar", "barão do rio")); - Assert.Equal(InvalidCityMessage, exception.Message); - - exception = Assert.Throws(() => _service.Execute("SP", "Ara", "ba")); - Assert.Equal(InvalidStreetMessage, exception.Message); - - exception = Assert.Throws(() => _service.Execute("", "Araraquara", "barão do rio")); - Assert.Equal(InvalidStateMessage, exception.Message); - - exception = Assert.Throws(() => _service.Execute("SP", "", "barão do rio")); - Assert.Equal(InvalidCityMessage, exception.Message); - - exception = Assert.Throws(() => _service.Execute("SP", "Ara", "")); - Assert.Equal(InvalidStreetMessage, exception.Message); - } - - [Fact] - public async Task MustGetSingleZipCodeJsonStringAsync() - { - var actual = await _service.ExecuteAsync(ZipCodeTest); - - Assert.Equal(ExpectedResponse, actual); - } - - [Fact] - public async Task MustGetListZipCodeJsonStringAsync() - { - var actual = await _serviceList.ExecuteAsync(ViaCepParameterState, ViaCepParameterCity, ViaCepParameterStreet); - - Assert.Equal(ExpectedListResponse, actual); - } - - [Fact] - public async Task MustGetSingleZipCodeObjectAsync() - { - var actual = await _service.GetAddressAsync(ZipCodeTest); - - Assert.IsType(actual); - Assert.Equal(ExpectedCity, actual.City); - Assert.Equal(ExpectedState, actual.State); - } - - [Fact] - public async Task MustGetZipCodeObjectListAsync() - { - var actual = await _serviceList.ListAddressesAsync(ViaCepParameterState, ViaCepParameterCity, ViaCepParameterStreet); - - Assert.IsType>(actual); - Assert.True(actual.Count > 0); - Assert.Equal(ExpectedCity, actual[0].City); - Assert.Equal(ExpectedState, actual[0].State); - } - - [Fact] - public async Task MustThrowTheExceptionsAsync() - { - var exception = await Assert.ThrowsAsync(() => _service.ExecuteAsync(" 12345-67 ")); - Assert.Equal(InvalidZipCodeSizeMessage, exception.Message); - - exception = await Assert.ThrowsAsync(() => _service.ExecuteAsync(" 123A5-678 ")); - Assert.Equal(InvalidZipCodeFormatMessage, exception.Message); - - exception = await Assert.ThrowsAsync(() => _service.ExecuteAsync("U", "Araraquara", "barão do rio")); - Assert.Equal(InvalidStateMessage, exception.Message); - - exception = await Assert.ThrowsAsync(() => _service.ExecuteAsync("SP", "Ar", "barão do rio")); - Assert.Equal(InvalidCityMessage, exception.Message); - - exception = await Assert.ThrowsAsync(() => _service.ExecuteAsync("SP", "Ara", "ba")); - Assert.Equal(InvalidStreetMessage, exception.Message); - - exception = await Assert.ThrowsAsync(() => _service.ExecuteAsync("", "Araraquara", "barão do rio")); - Assert.Equal(InvalidStateMessage, exception.Message); - - exception = await Assert.ThrowsAsync(() => _service.ExecuteAsync("SP", "", "barão do rio")); - Assert.Equal(InvalidCityMessage, exception.Message); - - exception = await Assert.ThrowsAsync(() => _service.ExecuteAsync("SP", "Ara", "")); - Assert.Equal(InvalidStreetMessage, exception.Message); - } - } -} \ No newline at end of file +using CoreZipCode.Interfaces; +using CoreZipCode.Result; +using CoreZipCode.Services.ZipCode.ViaCepApi; +using Moq; +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Xunit; + +namespace CoreZipCode.Tests.Services.ZipCode.ViaCepApi +{ + public class ViaCepTest + { + private const string ValidCep = "14810100"; + private const string SingleAddressJson = "{\n \"cep\": \"14810-100\",\n \"logradouro\": \"Rua Barão do Rio Branco\",\n \"complemento\": \"\",\n \"bairro\": \"Vila Xavier (Vila Xavier)\",\n \"localidade\": \"Araraquara\",\n \"uf\": \"SP\",\n \"ibge\": \"3503208\",\n \"gia\": \"1818\",\n \"ddd\": \"16\",\n \"siafi\": \"7107\"\n}"; + private const string ListAddressJson = "[\n {\n \"cep\": \"14810-100\",\n \"logradouro\": \"Rua Barão do Rio Branco\",\n \"complemento\": \"\",\n \"bairro\": \"Vila Xavier (Vila Xavier)\",\n \"localidade\": \"Araraquara\",\n \"uf\": \"SP\",\n \"ibge\": \"3503208\",\n \"gia\": \"1818\",\n \"ddd\": \"16\",\n \"siafi\": \"7107\" }\n]"; + + private readonly Mock _handlerMock = new(); + private readonly ViaCep _service; + + public ViaCepTest() + { + _service = new ViaCep(_handlerMock.Object); + } + + [Fact] + public void Constructor_Creates_Instance() + { + new ViaCep(); + } + + [Fact] + public void Constructor_Creates_Instance_With_HttpClient() + { + new ViaCep(new HttpClient()); + } + + [Fact] + public void Constructor_With_Null_ApiHandler_Should_Throw_ArgumentNullException() + { + Assert.Throws(() => new ViaCep((IApiHandler)null)); + } + + [Fact] + public async Task GetAddressAsync_ValidCep_Returns_Success() + { + _handlerMock + .Setup(x => x.CallApiAsync($"https://viacep.com.br/ws/{ValidCep}/json/")) + .ReturnsAsync(Result.Success(SingleAddressJson)); + + var result = await _service.GetAddressAsync(ValidCep); + + Assert.True(result.IsSuccess); + Assert.Equal("Araraquara", result.Value.City); + Assert.Equal("SP", result.Value.State); + } + + [Fact] + public async Task GetAddressAsync_ApiReturns404_Returns_Failure() + { + _handlerMock + .Setup(x => x.CallApiAsync(It.IsAny())) + .ReturnsAsync(Result.Failure(new ApiError(HttpStatusCode.NotFound, "Not found"))); + + var result = await _service.GetAddressAsync("00000000"); + + Assert.True(result.IsFailure); + Assert.Equal(HttpStatusCode.NotFound, result.Error.StatusCode); + } + + [Fact] + public async Task GetAddressAsync_ApiReturnsNullBody_Returns_NotFound() + { + _handlerMock + .Setup(x => x.CallApiAsync(It.IsAny())) + .ReturnsAsync(Result.Success("null")); + + var result = await _service.GetAddressAsync(ValidCep); + + Assert.True(result.IsFailure); + Assert.Equal(HttpStatusCode.NotFound, result.Error.StatusCode); + Assert.Equal("Address not found or empty response.", result.Error.Message); + } + + [Fact] + public async Task GetAddressAsync_ApiReturnsInvalidJson_Returns_UnprocessableEntity() + { + var invalidJson = "{ invalid json }"; + _handlerMock + .Setup(x => x.CallApiAsync(It.IsAny())) + .ReturnsAsync(Result.Success(invalidJson)); + + var result = await _service.GetAddressAsync(ValidCep); + + Assert.True(result.IsFailure); + Assert.Equal(HttpStatusCode.UnprocessableEntity, result.Error.StatusCode); + Assert.Equal("Failed to parse API response.", result.Error.Message); + Assert.NotNull(result.Error.Detail); + Assert.Equal(invalidJson, result.Error.ResponseBody); + } + + [Fact] + public async Task GetAddressAsync_InvalidCep_Throws_ViaCepException() + { + var ex = await Assert.ThrowsAsync( + () => _service.GetAddressAsync("123")); + + Assert.Contains("Invalid ZipCode Size", ex.Message); + } + + [Fact] + public async Task GetAddressAsync_NonNumericCep_Throws_ViaCepException() + { + var ex = await Assert.ThrowsAsync( + () => _service.GetAddressAsync("ABCDEF12")); + + Assert.Contains("Invalid ZipCode Format", ex.Message); + } + + [Fact] + public async Task ListAddressesAsync_ValidParams_Returns_Success_List() + { + _handlerMock + .Setup(x => x.CallApiAsync("https://viacep.com.br/ws/SP/Araraquara/barão do rio/json/")) + .ReturnsAsync(Result.Success(ListAddressJson)); + + var result = await _service.ListAddressesAsync("SP", "Araraquara", "barão do rio"); + + Assert.True(result.IsSuccess); + Assert.Single(result.Value); + Assert.Equal("Araraquara", result.Value[0].City); + Assert.Equal("SP", result.Value[0].State); + } + + [Fact] + public async Task ListAddressesAsync_ApiReturnsEmptyList_Returns_Success_EmptyList() + { + _handlerMock + .Setup(x => x.CallApiAsync(It.IsAny())) + .ReturnsAsync(Result.Success("[]")); + + var result = await _service.ListAddressesAsync("SP", "City", "Street"); + + Assert.True(result.IsSuccess); + Assert.Empty(result.Value); + } + + [Fact] + public async Task ListAddressesAsync_ApiReturnsNull_Returns_Success_EmptyList() + { + _handlerMock + .Setup(x => x.CallApiAsync(It.IsAny())) + .ReturnsAsync(Result.Success("null")); + + var result = await _service.ListAddressesAsync("SP", "City", "Street"); + + Assert.True(result.IsSuccess); + Assert.Empty(result.Value); + } + + [Fact] + public async Task ListAddressesAsync_ApiReturnsInvalidJson_Returns_UnprocessableEntity() + { + var invalidJson = "[ invalid json ]"; + _handlerMock + .Setup(x => x.CallApiAsync(It.IsAny())) + .ReturnsAsync(Result.Success(invalidJson)); + + var result = await _service.ListAddressesAsync("SP", "City", "Street"); + + Assert.True(result.IsFailure); + Assert.Equal(HttpStatusCode.UnprocessableEntity, result.Error.StatusCode); + Assert.Equal("Failed to parse address list.", result.Error.Message); + Assert.NotNull(result.Error.Detail); + Assert.Equal(invalidJson, result.Error.ResponseBody); + } + + [Fact] + public async Task ListAddressesAsync_ApiReturnsFailure_Returns_Failure() + { + var error = new ApiError(HttpStatusCode.ServiceUnavailable, "Service unavailable"); + _handlerMock + .Setup(x => x.CallApiAsync(It.IsAny())) + .ReturnsAsync(Result.Failure(error)); + + var result = await _service.ListAddressesAsync("SP", "City", "Street"); + + Assert.True(result.IsFailure); + Assert.Equal(HttpStatusCode.ServiceUnavailable, result.Error.StatusCode); + Assert.Equal("Service unavailable", result.Error.Message); + } + + [Fact] + public async Task ListAddressesAsync_InvalidState_Throws_ViaCepException() + { + var ex = await Assert.ThrowsAsync( + () => _service.ListAddressesAsync("X", "City", "Street")); + + Assert.Contains("Invalid State", ex.Message); + } + + [Fact] + public async Task ListAddressesAsync_EmptyState_Throws_ViaCepException() + { + var ex = await Assert.ThrowsAsync( + () => _service.ListAddressesAsync("", "City", "Street")); + + Assert.Contains("Invalid State", ex.Message); + } + + [Fact] + public async Task ListAddressesAsync_InvalidCity_Throws_ViaCepException() + { + var ex = await Assert.ThrowsAsync( + () => _service.ListAddressesAsync("SP", "AB", "Street")); + + Assert.Contains("Invalid City", ex.Message); + } + + [Fact] + public async Task ListAddressesAsync_EmptyCity_Throws_ViaCepException() + { + var ex = await Assert.ThrowsAsync( + () => _service.ListAddressesAsync("SP", "", "Street")); + + Assert.Contains("Invalid City", ex.Message); + } + + [Fact] + public async Task ListAddressesAsync_InvalidStreet_Throws_ViaCepException() + { + var ex = await Assert.ThrowsAsync( + () => _service.ListAddressesAsync("SP", "City", "AB")); + + Assert.Contains("Invalid Street", ex.Message); + } + + [Fact] + public async Task ListAddressesAsync_EmptyStreet_Throws_ViaCepException() + { + var ex = await Assert.ThrowsAsync( + () => _service.ListAddressesAsync("SP", "City", "")); + + Assert.Contains("Invalid Street", ex.Message); + } + + [Fact] + [Obsolete("Will be removed in next version")] + public async Task ExecuteAsync_ValidCep_Returns_Json_String() + { + _handlerMock + .Setup(x => x.CallApiAsync($"https://viacep.com.br/ws/{ValidCep}/json/")) + .ReturnsAsync(Result.Success(SingleAddressJson)); + + var result = await _service.ExecuteAsync(ValidCep); + + Assert.Equal(SingleAddressJson, result); + } + + [Fact] + [Obsolete("Will be removed in next version")] + public async Task ExecuteAsync_ApiReturnsFailure_Throws_HttpRequestException() + { + var error = new ApiError(HttpStatusCode.NotFound, "Not found"); + _handlerMock + .Setup(x => x.CallApiAsync(It.IsAny())) + .ReturnsAsync(Result.Failure(error)); + + var ex = await Assert.ThrowsAsync( + () => _service.ExecuteAsync(ValidCep)); + + Assert.Equal("Not found", ex.Message); + } + + [Fact] + [Obsolete("Will be removed in next version")] + public void Execute_Always_Throws_NotSupportedException() + { + Assert.Throws(() => _service.Execute(ValidCep)); + } + + [Fact] + [Obsolete("Will be removed in next version")] + public void GetAddress_Always_Throws_NotSupportedException() + { + Assert.Throws(() => _service.GetAddress(ValidCep)); + } + + [Fact] + public void SetZipCodeUrl_Generates_Correct_Url() + { + var url = _service.SetZipCodeUrl("01001000"); + Assert.Equal("https://viacep.com.br/ws/01001000/json/", url); + } + + [Fact] + public void SetZipCodeUrl_With_Hyphen_Generates_Correct_Url() + { + var url = _service.SetZipCodeUrl("01001-000"); + Assert.Equal("https://viacep.com.br/ws/01001000/json/", url); + } + + [Fact] + public void SetZipCodeUrl_With_Spaces_Generates_Correct_Url() + { + var url = _service.SetZipCodeUrl(" 01001000 "); + Assert.Equal("https://viacep.com.br/ws/01001000/json/", url); + } + + [Fact] + public void SetZipCodeUrlBy_Generates_Correct_Url() + { + var url = _service.SetZipCodeUrlBy("rj", "rio de janeiro", "praia de botafogo"); + Assert.Equal("https://viacep.com.br/ws/rj/rio de janeiro/praia de botafogo/json/", url); + } + + [Fact] + public void SetZipCodeUrlBy_With_Spaces_Generates_Correct_Url() + { + var url = _service.SetZipCodeUrlBy(" SP ", " Araraquara ", " Street Name "); + Assert.Equal("https://viacep.com.br/ws/SP/Araraquara/Street Name/json/", url); + } + } +} diff --git a/CoreZipCode/CoreZipCode.csproj b/CoreZipCode/CoreZipCode.csproj index b881b3c..fc7c6f7 100644 --- a/CoreZipCode/CoreZipCode.csproj +++ b/CoreZipCode/CoreZipCode.csproj @@ -5,7 +5,7 @@ CoreZipCode CoreZipCode - 2.0.9 + 2.1.0 Danilo Lutz Danilo Lutz https://github.com/danilolutz/CoreZipCode/blob/master/LICENSE diff --git a/CoreZipCode/Interfaces/ApiHandler.cs b/CoreZipCode/Interfaces/ApiHandler.cs index f50329d..29fff7b 100644 --- a/CoreZipCode/Interfaces/ApiHandler.cs +++ b/CoreZipCode/Interfaces/ApiHandler.cs @@ -1,3 +1,4 @@ +using CoreZipCode.Result; using System; using System.Net; using System.Net.Http; @@ -8,75 +9,78 @@ namespace CoreZipCode.Interfaces /// /// ApiHandler class /// - public class ApiHandler + public class ApiHandler : IApiHandler { /// /// Http Client Request. /// - private readonly HttpClient _request; + private readonly HttpClient _httpClient; /// /// ApiHandler Constructor without parameter: request. /// - public ApiHandler() - { - _request = new HttpClient(); - } + public ApiHandler() : this(new HttpClient()) { } /// /// ApiHandler Constructor with parameter: request. /// - /// HttpClient class param to handle with API Servers Connections. - public ApiHandler(HttpClient request) + /// HttpClient class param to handle with API Servers Connections. + public ApiHandler(HttpClient httpClient) { - _request = request; + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); } /// - /// Method to execute the api call. + /// Sends an asynchronous HTTP GET request to the specified URL and returns the response content or an error + /// result. /// - /// Api url to execute - /// String Server response - public virtual string CallApi(string url) + /// If the request fails due to network issues, timeouts, or a non-success HTTP status + /// code, the returned result will contain an describing the failure. The method does not + /// throw exceptions for typical HTTP or network errors; instead, errors are encapsulated in the result + /// object. + /// The absolute URL of the API endpoint to call. Cannot be null, empty, or whitespace. + /// A containing the response body if the request succeeds; otherwise, a failure + /// result with error details. + public virtual async Task> CallApiAsync(string url) { + if (string.IsNullOrWhiteSpace(url)) + return Result.Failure( + new ApiError(HttpStatusCode.BadRequest, "URL cannot be null or empty.")); + try { - var response = _request.GetAsync(url).Result; + var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead) + .ConfigureAwait(false); + + var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - if (response.StatusCode == HttpStatusCode.BadRequest) - { - throw new ArgumentException(); - } + if (response.IsSuccessStatusCode) + return Result.Success(body); - return response.Content.ReadAsStringAsync().Result; + var errorMessage = $"API returned {(int)response.StatusCode} {response.StatusCode}"; + var error = new ApiError(response.StatusCode, errorMessage, response.ReasonPhrase, body); + + return Result.Failure(error); } - catch (Exception ex) + catch (HttpRequestException ex) { - throw new HttpRequestException($"Error trying execute the request: {ex.Message}"); + return Result.Failure( + new ApiError(HttpStatusCode.ServiceUnavailable, "Network or connection error.", ex.Message)); } - } - - /// - /// Method to execute the api call async. - /// - /// Api url to execute async - /// String Server response - public virtual async Task CallApiAsync(string url) - { - try + catch (TaskCanceledException ex) { - var response = await _request.GetAsync(url); - - if (response.StatusCode == HttpStatusCode.BadRequest) - { - throw new ArgumentException(); - } - - return await response.Content.ReadAsStringAsync(); + return Result.Failure( + new ApiError(HttpStatusCode.RequestTimeout, "Request timed out.", ex.Message)); + } + catch (OperationCanceledException ex) + { + return Result.Failure( + new ApiError(HttpStatusCode.BadRequest, "Request was cancelled.", ex.Message)); } catch (Exception ex) { - throw new HttpRequestException($"Error trying execute the request: {ex.Message}"); + return Result.Failure( + new ApiError(HttpStatusCode.InternalServerError, "Unexpected error.", ex.Message)); } } } diff --git a/CoreZipCode/Interfaces/IApiHandler.cs b/CoreZipCode/Interfaces/IApiHandler.cs new file mode 100644 index 0000000..912ecf3 --- /dev/null +++ b/CoreZipCode/Interfaces/IApiHandler.cs @@ -0,0 +1,10 @@ +using CoreZipCode.Result; +using System.Threading.Tasks; + +namespace CoreZipCode.Interfaces +{ + public interface IApiHandler + { + Task> CallApiAsync(string url); + } +} diff --git a/CoreZipCode/Interfaces/IPostcodeService.cs b/CoreZipCode/Interfaces/IPostcodeService.cs new file mode 100644 index 0000000..46c2232 --- /dev/null +++ b/CoreZipCode/Interfaces/IPostcodeService.cs @@ -0,0 +1,10 @@ +using CoreZipCode.Result; +using System.Threading.Tasks; + +namespace CoreZipCode.Interfaces +{ + public interface IPostcodeService + { + Task> GetPostcodeAsync(string postcode) where T : class; + } +} diff --git a/CoreZipCode/Interfaces/IZipCodeService.cs b/CoreZipCode/Interfaces/IZipCodeService.cs new file mode 100644 index 0000000..abca692 --- /dev/null +++ b/CoreZipCode/Interfaces/IZipCodeService.cs @@ -0,0 +1,12 @@ +using CoreZipCode.Result; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace CoreZipCode.Interfaces +{ + public interface IZipCodeService + { + Task> GetAddressAsync(string zipCode) where T : class; + Task>> ListAddressesAsync(string state, string city, string street) where T : class; + } +} diff --git a/CoreZipCode/Interfaces/PostCodeBaseService.cs b/CoreZipCode/Interfaces/PostCodeBaseService.cs index ca5af9c..76cb19b 100644 --- a/CoreZipCode/Interfaces/PostCodeBaseService.cs +++ b/CoreZipCode/Interfaces/PostCodeBaseService.cs @@ -1,4 +1,6 @@ +using CoreZipCode.Result; using Newtonsoft.Json; +using System; using System.Net.Http; using System.Threading.Tasks; @@ -7,54 +9,122 @@ namespace CoreZipCode.Interfaces /// /// Postcode base service abstract class. /// - public abstract class PostcodeBaseService : ApiHandler + public abstract class PostcodeBaseService : IPostcodeService { + private readonly IApiHandler _apiHandler; + /// - /// postalcode base service protected constructor. + /// Initializes a new instance of the PostcodeBaseService class using a default ApiHandler. /// - protected PostcodeBaseService() { } + /// This protected constructor is intended for use by derived classes to provide a + /// default API handler. For custom API handler configuration, use the constructor that accepts an ApiHandler + /// parameter. + protected PostcodeBaseService() + : this(new ApiHandler()) + { + } /// - /// postalcode base service protected constructor. + /// Initializes a new instance of the PostcodeBaseService class using the specified HTTP client for API + /// communication. /// - /// HttpClient class param to handle with API Servers Connections. - protected PostcodeBaseService(HttpClient request) : base(request) { } + /// This constructor allows customization of the underlying HTTP client, enabling + /// configuration of timeouts, headers, or other HTTP settings as needed for API interactions. + /// The HTTP client used to send requests to the postcode API. Cannot be null. + protected PostcodeBaseService(HttpClient httpClient) + : this(new ApiHandler(httpClient)) + { + } /// - /// Execute the address query by zip code. + /// Initializes a new instance of the PostcodeBaseService class with the specified API handler. /// - /// Postcode to find for - /// String server response - public virtual string Execute(string postcode) => CallApi(SetPostcodeUrl(postcode)); + /// The API handler used to perform requests to the underlying postcode service. Cannot be null. + /// Thrown if is null. + protected PostcodeBaseService(IApiHandler apiHandler) + { + _apiHandler = apiHandler ?? throw new ArgumentNullException(nameof(apiHandler)); + } /// - /// Execute the address query async by zip code. + /// Executes a synchronous postcode lookup and returns the result as a string. This method is obsolete; use + /// GetPostcodeAsync for improved error handling and asynchronous operation. /// - /// Postcode to find for - /// String server response - public virtual async Task ExecuteAsync(string postcode) => await CallApiAsync(SetPostcodeUrl(postcode)); + /// This method is obsolete and always throws a NotSupportedException. For better error + /// handling and asynchronous support, use GetPostcodeAsync instead. + /// The postcode to look up. Cannot be null or empty. + /// A string containing the result of the postcode lookup. + /// Thrown in all cases. This method is not supported; use the asynchronous alternative. + [Obsolete("Use GetPostcodeAsync witch returns Result for better error handling.")] + public virtual string Execute(string postcode) => throw new NotSupportedException("Use async version with Result."); /// - /// Run the address query by zip code, filling in the generic object. + /// Retrieves postcode information synchronously. This method is obsolete; use GetPostcodeAsync instead. /// - /// Generic object parameter - /// Postcode to find for - /// String server response - public virtual T GetPostcode(string postcode) => JsonConvert.DeserializeObject(CallApi(SetPostcodeUrl(postcode))); + /// This method is obsolete and always throws a NotSupportedException. Use + /// GetPostcodeAsync, which returns a Result, for asynchronous operations. + /// The type of the result returned for the postcode information. + /// The postcode to retrieve information for. Cannot be null or empty. + /// The postcode information of type T. + /// Thrown in all cases. This method is not supported; use the asynchronous version instead. + [Obsolete("Use GetPostcodeAsync witch returns Result.")] + public virtual T GetPostcode(string postcode) => throw new NotSupportedException("Use async version with Result."); + + /// + /// Asynchronously retrieves postcode information and returns the result as a strongly typed object. + /// + /// If the postcode is not found or the API call fails, the returned result will indicate + /// failure and include error information. This method is typically used to fetch and deserialize postcode data + /// from an external API. + /// The type of the object to deserialize the postcode information into. Must be a reference type. + /// The postcode to query. Cannot be null or empty. + /// A task that represents the asynchronous operation. The task result contains a object + /// with the postcode information if successful; otherwise, contains error details. + public virtual async Task> GetPostcodeAsync(string postcode) where T : class + { + var url = SetPostcodeUrl(postcode); + var result = await _apiHandler.CallApiAsync(url); + + return await result.Match( + onSuccess: body => HandleSuccess(body), + onFailure: error => Task.FromResult(Result.Failure(error))); + } /// - /// Run the address query by asynchronous zip code, filling in the generic object. + /// Deserializes the specified JSON string into an object of type T and returns a successful result if parsing + /// succeeds; otherwise, returns a failure result with an appropriate error. /// - /// Generic object parameter - /// Postcode to find for - /// String server response - public virtual async Task GetPostcodeAsync(string postcode) => JsonConvert.DeserializeObject(await CallApiAsync(SetPostcodeUrl(postcode))); + /// If the JSON string cannot be parsed or does not contain a valid object of type T, the + /// returned result will indicate failure and include error information. This method does not throw exceptions + /// for invalid JSON; instead, errors are encapsulated in the result. + /// The type of object to deserialize from the JSON string. Must be a reference type. + /// The JSON string representing the object to deserialize. Cannot be null. + /// A Result containing the deserialized object if successful; otherwise, a failure result with error details + /// if the JSON is invalid or does not represent a valid object. + private static async Task> HandleSuccess(string json) where T : class + { + try + { + var obj = JsonConvert.DeserializeObject(json); + return obj is null + ? Result.Failure(new ApiError(System.Net.HttpStatusCode.NotFound, "Postcode not found or empty response.")) + : Result.Success(obj); + } + catch (JsonException ex) + { + return Result.Failure(new ApiError( + System.Net.HttpStatusCode.UnprocessableEntity, + "Failed to parse postcode API response.", + ex.Message, + json)); + } + } /// - /// Method for implementation to mount the api url of query by postal code. + /// Generates a URL for retrieving information related to the specified postcode. /// - /// Postcode to find for - /// Url to get api by postal code + /// The postcode for which to generate the URL. Cannot be null or empty. + /// A string containing the URL associated with the provided postcode. public abstract string SetPostcodeUrl(string postcode); } -} \ No newline at end of file +} diff --git a/CoreZipCode/Interfaces/ZipCodeBaseService.cs b/CoreZipCode/Interfaces/ZipCodeBaseService.cs index 7624f49..a3316fa 100644 --- a/CoreZipCode/Interfaces/ZipCodeBaseService.cs +++ b/CoreZipCode/Interfaces/ZipCodeBaseService.cs @@ -1,108 +1,211 @@ -using Newtonsoft.Json; +using CoreZipCode.Result; +using Newtonsoft.Json; +using System; using System.Collections.Generic; +using System.Net; using System.Net.Http; using System.Threading.Tasks; namespace CoreZipCode.Interfaces { /// - /// Zip code base service abstract class - /// - public abstract class ZipCodeBaseService : ApiHandler +    /// Zip code base service abstract class +    /// + public abstract class ZipCodeBaseService : IZipCodeService { + private readonly IApiHandler _apiHandler; + + /// - /// Zip Code Base Service protected constructor. + /// Initializes a new instance of the ZipCodeBaseService class using a default ApiHandler. /// - protected ZipCodeBaseService() { } + /// This protected constructor is intended for use by derived classes to provide a + /// default configuration. It creates the service with a new instance of ApiHandler, which handles API + /// communication. For custom API handler configurations, use the constructor that accepts an ApiHandler + /// parameter. + protected ZipCodeBaseService() + : this(new ApiHandler()) + { + } /// - /// Zip Code Base Service protected constructor. + /// Initializes a new instance of the ZipCodeBaseService class using the specified HTTP client for API + /// communication. /// - /// HttpClient class param to handle with API Servers Connections. - protected ZipCodeBaseService(HttpClient request) : base(request) { } + /// This constructor allows for custom configuration of the underlying HTTP client, such + /// as setting default headers or timeouts, before making API requests. + /// The HTTP client used to send requests to the ZipCodeBase API. Cannot be null. + protected ZipCodeBaseService(HttpClient httpClient) + : this(new ApiHandler(httpClient)) + { + } /// - /// Execute the address query by zip code. + /// Initializes a new instance of the ZipCodeBaseService class with the specified API handler. /// - /// Zip code to find for - /// String server response - public virtual string Execute(string zipCode) => CallApi(SetZipCodeUrl(zipCode)); + /// The API handler used to perform requests to the underlying ZIP code service. Cannot be null. + /// Thrown if is null. + protected ZipCodeBaseService(IApiHandler apiHandler) + { + _apiHandler = apiHandler ?? throw new ArgumentNullException(nameof(apiHandler)); + } /// - /// Execute the address query by parameters: state, city, street. + /// Executes a synchronous address lookup for the specified ZIP code. This method is obsolete; use + /// GetAddressAsync with Result for improved error handling. /// - /// State to find for - /// City to find for - /// Street to find for - /// String server response - public virtual string Execute(string state, string city, string street) => CallApi(SetZipCodeUrlBy(state, city, street)); + /// Calling this method will always throw a NotSupportedException. For address lookups, + /// use GetAddressAsync with Result to handle errors more effectively. + /// The ZIP code to use for the address lookup. Must be a valid, non-empty string. + /// A string containing the address information associated with the specified ZIP code. + /// Always thrown. This method is not supported; use the asynchronous alternative. + [Obsolete("Use GetAddressAsync witch returns Result for better error handling.")] + public virtual string Execute(string zipCode) => throw new NotSupportedException("Use async version with Result."); /// - /// Execute the address query by zip code, filling the generic object. + /// Retrieves the address information for the specified ZIP code. This method is obsolete; use + /// GetAddressAsync with Result instead. /// - /// Generic object parameter - /// Zip code to find for - /// Generic object filled with postal code - public virtual T GetAddress(string zipCode) => JsonConvert.DeserializeObject(CallApi(SetZipCodeUrl(zipCode))); + /// This method is obsolete and not supported. For address retrieval, use + /// GetAddressAsync with Result to obtain results asynchronously. + /// The type of the address result to return. + /// The ZIP code for which to retrieve address information. Cannot be null or empty. + /// The address information of type T corresponding to the specified ZIP code. + /// Always thrown. This method is not supported; use the asynchronous version instead. + [Obsolete("Use GetAddressAsync witch returns Result.")] + public virtual T GetAddress(string zipCode) => throw new NotSupportedException("Use async version with Result."); /// - /// Execute the address list query by parameters: state, city, street. filling the generic object list. + /// Asynchronously retrieves address information for the specified ZIP code and returns the result as an object + /// of type . /// - /// Generic object parameter - /// State to find for - /// City to find for - /// Street to find for - /// Generic object list filled with zip code - public virtual IList ListAddresses(string state, string city, string street) => JsonConvert.DeserializeObject>(CallApi(SetZipCodeUrlBy(state, city, street))); + /// The type of the address result object. Must be a reference type. + /// The ZIP code for which to retrieve address information. Cannot be null or empty. + /// A task that represents the asynchronous operation. The task result contains a object + /// with the address information if successful; otherwise, contains error details. + public virtual async Task> GetAddressAsync(string zipCode) where T : class + { + var url = SetZipCodeUrl(zipCode); + var result = await _apiHandler.CallApiAsync(url); + + return await result.Match( + onSuccess: body => HandleSuccess(body), + onFailure: error => Task.FromResult(Result.Failure(error))); + } /// - /// Execute the address query async by zip code. + /// Asynchronously retrieves a list of address records matching the specified state, city, and street. /// - /// Zip code to find for - /// String server response - public virtual async Task ExecuteAsync(string zipCode) => await CallApiAsync(SetZipCodeUrl(zipCode)); + /// If no addresses match the specified criteria, the returned list will be empty. The + /// operation may fail if the API is unreachable or returns an error. + /// The type of address record to return. Must be a reference type. + /// The state to filter addresses by. Cannot be null or empty. + /// The city to filter addresses by. Cannot be null or empty. + /// The street to filter addresses by. Cannot be null or empty. + /// A task that represents the asynchronous operation. The task result contains a + /// with the list of matching address records if successful; otherwise, contains error information. + public virtual async Task>> ListAddressesAsync(string state, string city, string street) where T : class + { + var url = SetZipCodeUrlBy(state, city, street); + var result = await _apiHandler.CallApiAsync(url); + + return await result.Match( + onSuccess: body => HandleSuccessList(body), + onFailure: error => Task.FromResult(Result>.Failure(error))); + } /// - /// Execute the address query async by parameters: state, city, street. + /// Deserializes the specified JSON string into an object of type and returns a + /// successful result if parsing succeeds; otherwise, returns a failure result with an appropriate error. /// - /// State to find for - /// City to find for - /// Street to find for - /// string in json format filled with zip code - public virtual async Task ExecuteAsync(string state, string city, string street) => await CallApiAsync(SetZipCodeUrlBy(state, city, street)); + /// If the JSON string cannot be parsed or does not represent a valid object of type + /// , the returned result will indicate failure and include error details. The method + /// returns a failure result with a 'NotFound' error if deserialization yields null, or an 'UnprocessableEntity' + /// error if parsing fails due to invalid JSON. + /// The type to deserialize the JSON string into. Must be a reference type. + /// The JSON string representing the object to deserialize. Cannot be null. + /// A containing the deserialized object if successful; otherwise, a failure result with + /// an error describing the issue. + private static async Task> HandleSuccess(string json) where T : class + { + try + { + var obj = JsonConvert.DeserializeObject(json); + return obj is null + ? Result.Failure(new ApiError(HttpStatusCode.NotFound, "Address not found or empty response.")) + : Result.Success(obj); + } + catch (JsonException ex) + { + return Result.Failure(new ApiError( + HttpStatusCode.UnprocessableEntity, + "Failed to parse API response.", + ex.Message, + json)); + } + } /// - /// Run the address query by asynchronous zip code, filling in the generic object. + /// Deserializes the specified JSON string into a list of objects of type T and returns a success result. If + /// deserialization fails, returns a failure result containing error details. /// - /// Generic object parameter - /// Zip code to find for - /// Generic object filled with postal code - public virtual async Task GetAddressAsync(string zipCode) => JsonConvert.DeserializeObject(await CallApiAsync(SetZipCodeUrl(zipCode))); + /// If the JSON string is valid but empty or null, an empty list is returned as a + /// successful result. If the JSON is invalid or cannot be parsed, the result contains an error with details + /// about the failure. + /// The type of objects to deserialize from the JSON string. Must be a reference type. + /// The JSON string representing a list of objects to deserialize. Cannot be null. + /// A Result containing the deserialized list of objects of type T if successful; otherwise, a failure result + /// with error information. + private static async Task>> HandleSuccessList(string json) where T : class + { + try + { + var list = JsonConvert.DeserializeObject>(json) ?? new List(); + return Result>.Success(list); + } + catch (JsonException ex) + { + return Result>.Failure(new ApiError( + HttpStatusCode.UnprocessableEntity, + "Failed to parse address list.", + ex.Message, + json)); + } + } /// - /// Run the address query by asynchronous zip code, filling in the generic object list. + /// Asynchronously retrieves the address information associated with the specified ZIP code. /// - /// Generic object parameter - /// State to find for - /// City to find for - /// Street to find for - /// Generic object list filled with zip code - public virtual async Task> ListAddressesAsync(string state, string city, string street) => JsonConvert.DeserializeObject>(await CallApiAsync(SetZipCodeUrlBy(state, city, street))); + /// This method is obsolete. Prefer using GetAddressAsync that returns Result for + /// improved error handling and type safety. + /// The ZIP code for which to retrieve address information. Cannot be null or empty. + /// A task that represents the asynchronous operation. The task result contains the address information as a + /// string. + /// Thrown if the API request fails or returns an error response. + [Obsolete("Prefer using GetAddressAsync that returns Result.")] + public virtual async Task ExecuteAsync(string zipCode) + { + var result = await _apiHandler.CallApiAsync(SetZipCodeUrl(zipCode)); + return result.Match(s => s, e => throw new HttpRequestException(e.Message)); + } /// - /// Method for implementation to mount the api url of query by postal code. + /// Generates a URL for accessing information related to the specified ZIP code. /// - /// Zip code to find for - /// Url to get api by postal code + /// The ZIP code for which to generate the URL. Must be a valid postal code; cannot be null or empty. + /// A string containing the URL associated with the provided ZIP code. public abstract string SetZipCodeUrl(string zipCode); /// - /// Method for implementation to mount the api url of query by parameters: state, city, street. + /// Generates a URL for retrieving ZIP code information based on the specified state, city, and street. /// - /// State to find for - /// City to find for - /// Street to find for - /// Url to get api by parameters: state, city, street + /// The format of the returned URL may vary depending on the implementation. Callers + /// should ensure that all parameters are valid and properly formatted to avoid errors. + /// The two-letter abbreviation or full name of the state for which to generate the ZIP code URL. Cannot be null + /// or empty. + /// The name of the city for which to generate the ZIP code URL. Cannot be null or empty. + /// The name of the street for which to generate the ZIP code URL. Cannot be null or empty. + /// A string containing the URL to access ZIP code information for the specified location. public abstract string SetZipCodeUrlBy(string state, string city, string street); } } \ No newline at end of file diff --git a/CoreZipCode/Result/ApiError.cs b/CoreZipCode/Result/ApiError.cs new file mode 100644 index 0000000..2f9cf53 --- /dev/null +++ b/CoreZipCode/Result/ApiError.cs @@ -0,0 +1,27 @@ +#nullable enable +using System; +using System.Net; + +namespace CoreZipCode.Result +{ + /// + /// Immutable representation of an API failure. + /// + public sealed class ApiError + { + public HttpStatusCode StatusCode { get; } + public string Message { get; } + public string? Detail { get; } + public string? ResponseBody { get; } + + public ApiError(HttpStatusCode statusCode, string message, string? detail = null, string? responseBody = null) + { + StatusCode = statusCode; + Message = message ?? throw new ArgumentNullException(nameof(message)); + Detail = detail; + ResponseBody = responseBody; + } + + public override string ToString() => $"{(int)StatusCode} {StatusCode}: {Message}"; + } +} \ No newline at end of file diff --git a/CoreZipCode/Result/Result.cs b/CoreZipCode/Result/Result.cs new file mode 100644 index 0000000..86df7c8 --- /dev/null +++ b/CoreZipCode/Result/Result.cs @@ -0,0 +1,47 @@ +using System; + +namespace CoreZipCode.Result +{ + /// + /// Immutable value object representing the outcome of an operation. + /// (no records, no init-only). + /// + public sealed class Result + { + public bool IsSuccess { get; } + public bool IsFailure => !IsSuccess; + + public T Value { get; } + public ApiError Error { get; } + + private Result(T value) + { + IsSuccess = true; + Value = value; + Error = null!; + } + + private Result(ApiError error) + { + IsSuccess = false; + Error = error ?? throw new ArgumentNullException(nameof(error)); + Value = default!; + } + + public static Result Success(T value) => new Result(value); + public static Result Failure(ApiError error) => new Result(error); + + public Result Map(Func mapper) + { + return IsSuccess + ? Result.Success(mapper(Value)) + : Result.Failure(Error); + } + + public TResult Match(Func onSuccess, Func onFailure) + { + return IsSuccess ? onSuccess(Value) : onFailure(Error); + } + } +} + diff --git a/CoreZipCode/Services/Postcode/PostalpincodeInApi/PostalpincodeIn.cs b/CoreZipCode/Services/Postcode/PostalpincodeInApi/PostalpincodeIn.cs index 5796c79..b2602d7 100644 --- a/CoreZipCode/Services/Postcode/PostalpincodeInApi/PostalpincodeIn.cs +++ b/CoreZipCode/Services/Postcode/PostalpincodeInApi/PostalpincodeIn.cs @@ -14,14 +14,11 @@ public class PostalpincodeIn : PostcodeBaseService { public PostalpincodeIn() { } - public PostalpincodeIn(HttpClient request) : base(request) - { - // - } + public PostalpincodeIn(HttpClient httpClient) : base(httpClient) { } + + public PostalpincodeIn(IApiHandler apiHandler) : base(apiHandler) { } public override string SetPostcodeUrl(string postcode) - { - return $"http://postalpincode.in/api/pincode/{postcode}"; - } + => $"http://postalpincode.in/api/pincode/{postcode.Trim()}"; } } \ No newline at end of file diff --git a/CoreZipCode/Services/Postcode/PostcodesIoApi/PostcodesIo.cs b/CoreZipCode/Services/Postcode/PostcodesIoApi/PostcodesIo.cs index 2f687af..9417259 100644 --- a/CoreZipCode/Services/Postcode/PostcodesIoApi/PostcodesIo.cs +++ b/CoreZipCode/Services/Postcode/PostcodesIoApi/PostcodesIo.cs @@ -1,4 +1,5 @@ using CoreZipCode.Interfaces; +using System; using System.Net.Http; namespace CoreZipCode.Services.Postcode.PostcodesIoApi @@ -12,16 +13,14 @@ namespace CoreZipCode.Services.Postcode.PostcodesIoApi /// information about the Postcodes API, see https://postcodes.io. public class PostcodesIo : PostcodeBaseService { + public PostcodesIo() { } - public PostcodesIo(HttpClient request) : base(request) - { - // - } + public PostcodesIo(HttpClient httpClient) : base(httpClient) { } + + public PostcodesIo(IApiHandler apiHandler) : base(apiHandler) { } public override string SetPostcodeUrl(string postcode) - { - return $"https://api.postcodes.io/postcodes?q={postcode}"; - } + => $"https://api.postcodes.io/postcodes?q={Uri.EscapeDataString(postcode.Trim())}"; } } \ No newline at end of file diff --git a/CoreZipCode/Services/ZipCode/SmartyApi/Smarty.cs b/CoreZipCode/Services/ZipCode/SmartyApi/Smarty.cs index 9d98785..69197e2 100644 --- a/CoreZipCode/Services/ZipCode/SmartyApi/Smarty.cs +++ b/CoreZipCode/Services/ZipCode/SmartyApi/Smarty.cs @@ -6,66 +6,105 @@ namespace CoreZipCode.Services.ZipCode.SmartyApi { /// - /// Provides access to Smarty ZIP code and street address lookup services using authenticated API requests. + /// Provides access to SmartyStreets ZIP code and street address lookup services using authenticated API requests. /// - /// Use this class to construct authenticated requests to the Smarty US ZIP code and - /// street address APIs. The class requires valid authentication credentials, which are used to authorize each - /// request. Inherit from to enable integration with other ZIP code service implementations. This - /// class is not thread-safe; create a separate instance for each concurrent operation if needed. For more information about the Smarty API, see - /// https://www.smarty.com/. + /// Use this class to construct authenticated requests to the SmartyStreets US ZIP code and + /// street address APIs. The class requires valid authentication credentials and offers methods for building request + /// URLs for ZIP code and address lookups. Instances can be initialized with authentication credentials alone, or + /// with a custom HTTP client or API handler for advanced scenarios. This class is not thread-safe if credentials or + /// handlers are modified externally. public class Smarty : ZipCodeBaseService { - private const string ZipCodeSizeErrorMessage = "Invalid ZipCode Size"; - private const string ZipCodeFormatErrorMessage = "Invalid ZipCode Format"; - private const string BaseZipcodeUrl = "https://us-zipcode.api.smartystreets.com/lookup"; private const string BaseStreetUrl = "https://us-street.api.smartystreets.com/street-address"; private readonly string _authId; private readonly string _authToken; + /// + /// Initializes a new instance of the Smarty class using the specified authentication credentials. + /// + /// The authentication identifier used to authorize API requests. Cannot be null, empty, or consist only of + /// white-space characters. + /// The authentication token used to authorize API requests. Cannot be null, empty, or consist only of + /// white-space characters. public Smarty(string authId, string authToken) { - _authId = string.IsNullOrWhiteSpace(authId) ? throw new ArgumentNullException(nameof(authId)) : authId; - _authToken = string.IsNullOrWhiteSpace(authToken) ? throw new ArgumentNullException(nameof(authToken)) : authToken; + _authId = Validate.NotNullOrWhiteSpace(authId, nameof(authId)); + _authToken = Validate.NotNullOrWhiteSpace(authToken, nameof(authToken)); } - public Smarty(HttpClient request, string authId, string authToken) : base(request) + /// + /// Initializes a new instance of the Smarty class using the specified HTTP client and authentication + /// credentials. + /// + /// The HTTP client instance used to send requests to the Smarty API. Must not be null. + /// The authentication identifier used to authorize requests. Must not be null or whitespace. + /// The authentication token used to authorize requests. Must not be null or whitespace. + public Smarty(HttpClient httpClient, string authId, string authToken) + : base(httpClient) { - _authId = string.IsNullOrWhiteSpace(authId) ? throw new ArgumentNullException(nameof(authId)) : authId; - _authToken = string.IsNullOrWhiteSpace(authToken) ? throw new ArgumentNullException(nameof(authToken)) : authToken; + _authId = Validate.NotNullOrWhiteSpace(authId, nameof(authId)); + _authToken = Validate.NotNullOrWhiteSpace(authToken, nameof(authToken)); } - public override string SetZipCodeUrl(string zipcode) => $"{BaseZipcodeUrl}?auth-id={_authId}&auth-token={_authToken}&zipcode={ValidateZipCode(zipcode)}"; + /// + /// Initializes a new instance of the Smarty class using the specified API handler and authentication + /// credentials. + /// + /// The API handler used to communicate with the Smarty service. Must not be null. + /// The authentication identifier for accessing the Smarty API. Must not be null or whitespace. + /// The authentication token for accessing the Smarty API. Must not be null or whitespace. + public Smarty(IApiHandler apiHandler, string authId, string authToken) + : base(apiHandler) + { + _authId = Validate.NotNullOrWhiteSpace(authId, nameof(authId)); + _authToken = Validate.NotNullOrWhiteSpace(authToken, nameof(authToken)); + } - public override string SetZipCodeUrlBy(string state, string city, string street) => $"{BaseStreetUrl}?auth-id={_authId}&auth-token={_authToken}&street={ValidateParam("Street", street)}&city={ValidateParam("City", city)}&state={ValidateParam("State", state, 32)}&candidates=10"; + /// + /// Constructs a URL for retrieving information related to the specified ZIP code, including authentication + /// parameters. + /// + /// The ZIP code to include in the URL. Must be a valid ZIP code format; otherwise, an exception may be thrown. + /// A string containing the fully constructed URL with authentication and ZIP code parameters. + public override string SetZipCodeUrl(string zipcode) + => $"{BaseZipcodeUrl}?auth-id={_authId}&auth-token={_authToken}&zipcode={ValidateZipCode(zipcode)}"; - private static string ValidateParam(string name, string value, int size = 64) - { - var aux = value; - if (aux.Length > size) - { - throw new SmartyException($"Invalid {name}, parameter over size of {size} characters."); - } + public override string SetZipCodeUrlBy(string state, string city, string street) + => $"{BaseStreetUrl}?auth-id={_authId}&auth-token={_authToken}" + + $"&street={ValidateParam("Street", street)}" + + $"&city={ValidateParam("City", city)}" + + $"&state={ValidateParam("State", state, 32)}" + + $"&candidates=10" + + $"&match=enhanced"; - return aux.Trim(); + private static string ValidateParam(string name, string value, int maxSize = 64) + { + var trimmed = value.Trim(); + if (trimmed.Length > maxSize) + throw new SmartyException($"Invalid {name}: exceeds {maxSize} characters."); + return Uri.EscapeDataString(trimmed); // Smarty demands URL encoding } private static string ValidateZipCode(string zipCode) { - var zipAux = zipCode.Trim().Replace("-", ""); + var clean = zipCode.Trim().Replace("-", ""); + if (clean.Length < 5 || clean.Length > 16) + throw new SmartyException("Invalid ZipCode Size"); - if (zipAux.Length < 5 || zipAux.Length > 16) - { - throw new SmartyException(ZipCodeSizeErrorMessage); - } + if (!Regex.IsMatch(clean, @"^\d{5,16}$")) + throw new SmartyException("Invalid ZipCode Format"); - if (!Regex.IsMatch(zipAux, ("[0-9]{5,16}"))) - { - throw new SmartyException(ZipCodeFormatErrorMessage); - } + return clean; + } - return zipAux; + private static class Validate + { + public static string NotNullOrWhiteSpace(string value, string paramName) + => string.IsNullOrWhiteSpace(value) + ? throw new ArgumentNullException(paramName) + : value; } } -} \ No newline at end of file +} diff --git a/CoreZipCode/Services/ZipCode/ViaCepApi/ViaCep.cs b/CoreZipCode/Services/ZipCode/ViaCepApi/ViaCep.cs index c5610e1..1584b9b 100644 --- a/CoreZipCode/Services/ZipCode/ViaCepApi/ViaCep.cs +++ b/CoreZipCode/Services/ZipCode/ViaCepApi/ViaCep.cs @@ -18,42 +18,32 @@ public class ViaCep : ZipCodeBaseService public ViaCep() { } - public ViaCep(HttpClient request) : base(request) - { - // - } + public ViaCep(HttpClient httpClient) : base(httpClient) { } - public override string SetZipCodeUrl(string zipCode) => $"https://viacep.com.br/ws/{ValidateZipCode(zipCode)}/json/"; + public ViaCep(IApiHandler apiHandler) : base(apiHandler) { } - public override string SetZipCodeUrlBy(string state, string city, string street) => $"https://viacep.com.br/ws/{ValidateParam("State", state, 2)}/{ValidateParam("City", city)}/{ValidateParam("Street", street)}/json/"; + public override string SetZipCodeUrl(string zipCode) + => $"https://viacep.com.br/ws/{ValidateZipCode(zipCode)}/json/"; + + public override string SetZipCodeUrlBy(string state, string city, string street) + => $"https://viacep.com.br/ws/{ValidateParam("State", state, 2)}/{ValidateParam("City", city)}/{ValidateParam("Street", street)}/json/"; private static string ValidateParam(string name, string value, int size = 3) { - var aux = value.Trim(); - - if (string.IsNullOrEmpty(aux) || aux.Length < size) - { + var trimmed = value.Trim(); + if (string.IsNullOrEmpty(trimmed) || trimmed.Length < size) throw new ViaCepException($"Invalid {name}, parameter below size of {size} characters."); - } - - return aux; + return trimmed; } private static string ValidateZipCode(string zipCode) { - var zipAux = zipCode.Trim().Replace("-", ""); - - if (zipAux.Length != 8) - { + var clean = zipCode.Trim().Replace("-", ""); + if (clean.Length != 8) throw new ViaCepException(ZipCodeSizeErrorMessage); - } - - if (!Regex.IsMatch(zipAux, ("[0-9]{8}"))) - { + if (!Regex.IsMatch(clean, @"^\d{8}$")) throw new ViaCepException(ZipCodeFormatErrorMessage); - } - - return zipAux; + return clean; } } -} \ No newline at end of file +} diff --git a/README.md b/README.md index 5afe5ab..2086dd1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # ![CoreZipCode](./docs/images/corezipcode.png) [![Build](https://github.com/danilolutz/CoreZipCode/actions/workflows/main.yml/badge.svg)](https://github.com/danilolutz/CoreZipCode/actions/workflows/main.yml) -[![Coverage Status](https://coveralls.io/repos/github/danilolutz/CoreZipCode/badge.svg?branch=master)](https://coveralls.io/github/danilolutz/CoreZipCode?branch=master) +[![Coverage Status](https://coveralls.io/repos/github/danilolutz/CoreZipCode/badge.svg?branch=main)](https://coveralls.io/github/danilolutz/CoreZipCode?branch=main) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/4e055c8f8dfb4d88961f19a6318c8b52)](https://www.codacy.com/gh/danilolutz/CoreZipCode/dashboard?utm_source=github.com&utm_medium=referral&utm_content=danilolutz/CoreZipCode&utm_campaign=Badge_Grade) [![Known Vulnerabilities](https://snyk.io/test/github/danilolutz/CoreZipCode/main/badge.svg)](https://snyk.io/test/github/danilolutz/CoreZipCode/main) [![CoreZipCode Nuget Package](https://img.shields.io/nuget/v/CoreZipCode.svg)](https://www.nuget.org/packages/CoreZipCode/) [![License: MIT](https://img.shields.io/badge/License-MIT-428f7e.svg)](https://opensource.org/licenses/MIT) diff --git a/SampleApp/Program.cs b/SampleApp/Program.cs index 9d2f5ae..c675540 100644 --- a/SampleApp/Program.cs +++ b/SampleApp/Program.cs @@ -1,130 +1,223 @@ // See https://aka.ms/new-console-template for more information +using CoreZipCode.Result; using CoreZipCode.Services.Postcode.PostalpincodeInApi; using CoreZipCode.Services.Postcode.PostcodesIoApi; using CoreZipCode.Services.ZipCode.SmartyApi; using CoreZipCode.Services.ZipCode.ViaCepApi; using Newtonsoft.Json; -static void ViaCepService() +Console.Title = "CoreZipCode Demo"; + +static async Task ViaCepServiceAsync() { Console.WriteLine("You picked up: ViaCep Service"); Console.WriteLine("Type your Brazilian Zipcode (eg: 14810100):"); - var zipcode = Console.ReadLine(); + var zipcode = Console.ReadLine()?.Trim(); - var objViaCepService = new ViaCep(); - Console.Write("Your execute method output is:\n"); - Console.Write(objViaCepService.Execute(zipcode)); + if (string.IsNullOrWhiteSpace(zipcode)) + { + Console.WriteLine("Invalid zipcode."); + Pause(); + return; + } - Console.Write("\n\nYour GetAddress method output is:\n"); - var addressModel = objViaCepService.GetAddress(zipcode); - Console.Write(JsonConvert.SerializeObject(addressModel).ToString()); + var service = new ViaCep(); + var resultByZip = await service.GetAddressAsync(zipcode); + await HandleResultAsync(resultByZip, "Address found by ZIP code!", "ZIP code not found or API error."); - Console.Write("\n\nType any key to turn back to menu"); - Console.ReadKey(); + if (resultByZip.IsSuccess && resultByZip.Value != null) + { + var address = resultByZip.Value; + + // Some fields may be empty – use fallback values for the demo + string state = string.IsNullOrWhiteSpace(address.State) ? "SP" : address.State; + string city = string.IsNullOrWhiteSpace(address.City) ? "São Paulo" : address.City; + string street = string.IsNullOrWhiteSpace(address.Address1) + ? "Avenida Paulista" // fallback street for demo + : address.Address1.Split(',')[0]; // take only the first part + + Console.WriteLine("\n=== Searching the same area by State + City + Street ==="); + Console.WriteLine($"State : {state}"); + Console.WriteLine($"City : {city}"); + Console.WriteLine($"Street: {street}"); + + // ViaCep also supports reverse lookup (state/city/street → list of addresses) + var resultByAddress = await service.ListAddressesAsync(state, city, street); + await HandleResultAsync(resultByAddress, + $"Found {(resultByAddress.Value!).Count} address(es) by State+City+Street!", + "No address found with the given State+City+Street."); + } } -static void SmartyService() +static async Task SmartyServiceAsync() { Console.WriteLine("You picked up: Smarty Service"); Console.WriteLine("Type your US Zipcode (eg: 95014):"); - var zipcode = Console.ReadLine(); + var zipcode = Console.ReadLine()?.Trim(); var authId = ""; // Create an account on https://www.smarty.com/ and put your own. var authToken = ""; // Create an account on https://www.smarty.com/ and put your own. - if (authId.Equals(string.Empty) || authToken.Equals(string.Empty)) + if (string.IsNullOrWhiteSpace(authId) || string.IsNullOrWhiteSpace(authToken)) { - Console.Write("You show provide yours access tokens"); - Console.ReadKey(); + Console.WriteLine("Error: Smarty credentials are missing. Fill authId/authToken in the code."); + Pause(); return; } - var objSmartyService = new Smarty(authId, authToken); - Console.Write("Your Execute method output is:\n"); - Console.Write(objSmartyService.Execute(zipcode)); + if (string.IsNullOrWhiteSpace(zipcode)) + { + Console.WriteLine("Invalid ZIP code."); + Pause(); + return; + } - Console.Write("\n\nYour GetAddress method output is:\n"); - var addressModel = objSmartyService.GetAddress>(zipcode); - Console.Write(JsonConvert.SerializeObject(addressModel).ToString()); + var service = new Smarty(authId, authToken); - Console.Write("\n\nType any key to turn back to menu"); - Console.ReadKey(); + var resultByZip = await service.GetAddressAsync>(zipcode); + await HandleResultAsync(resultByZip, "Addresses found by ZIP code!", "ZIP code not found or API error."); + + if (resultByZip.IsSuccess && resultByZip.Value != null && resultByZip.Value.Count > 0) + { + var firstResult = resultByZip.Value[0]; + + string state = firstResult.CityStates?[0]?.StateAbbreviation ?? "CA"; + string city = firstResult.CityStates?[0]?.City ?? "Beverly Hills"; + string street = "M"; + + Console.WriteLine("\n=== Searching the same area by State + City + Street ==="); + Console.WriteLine($"State : {state}"); + Console.WriteLine($"City : {city}"); + Console.WriteLine($"Street: {street} (demo street)"); + + // Use ListAddressesAsync (calls Smarty street-address endpoint) + var resultByAddress = await service.ListAddressesAsync(state, city, street); + + await HandleResultAsync( + resultByAddress, + $"Found {(resultByAddress.Value!).Count} matching address(es)!", + "No addresses found for the given street." + ); + } } -static void PostalpincodeInService() +static async Task PostalpincodeInServiceAsync() { Console.WriteLine("You picked up: PostalpicodeIn Service"); Console.WriteLine("Type your Indian Postalcode (eg: 744302):"); - var postcode = Console.ReadLine(); - var objPostalpincodeInService = new PostalpincodeIn(); - var result = objPostalpincodeInService.Execute(postcode); - Console.Write("Your Execute method output is:\n"); - Console.Write(result); + var pincode = Console.ReadLine()?.Trim(); - Console.Write("\nYour GetPostcode method output is:\n"); - var postModel = objPostalpincodeInService.GetPostcode(postcode); - Console.Write(JsonConvert.SerializeObject(postModel).ToString()); + var service = new PostalpincodeIn(); + var result = await service.GetPostcodeAsync(pincode); - Console.Write("\n\nType any key to turn back to menu"); - Console.ReadKey(); + await HandleResultAsync(result, "Pincode encontrado!", "Pincode não encontrado ou erro na API."); } -static void PostcodesIoService() +static async Task PostcodesIoServiceAsync() { Console.WriteLine("You picked up: Smarty Service"); Console.WriteLine("Type your UK Postcode (eg: CM81EF):"); - var postcode = Console.ReadLine(); - - var objPostcodesIoService = new PostcodesIo(); - var result = objPostcodesIoService.Execute(postcode); - Console.Write("Your Execute output is:\n"); - Console.Write(result); + var postcode = Console.ReadLine()?.Trim(); - Console.Write("\n\nYour GetPostcode method output is:\n"); - var postModel = objPostcodesIoService.GetPostcode(postcode); - Console.Write(JsonConvert.SerializeObject(postModel).ToString()); + var service = new PostcodesIo(); + var result = await service.GetPostcodeAsync(postcode); - Console.Write("\n\nType any key to turn back to menu"); - Console.ReadKey(); + await HandleResultAsync(result, "Postcode encontrado!", "Postcode não encontrado ou erro na API."); } static bool PrintMenu() { Console.Clear(); - Console.WriteLine(" ---------------------------------------"); - Console.WriteLine(" |**** CoreZipCode Demo Application ****|"); - Console.WriteLine(" ---------------------------------------"); + Console.WriteLine(" ╔══════════════════════════════════════════╗"); + Console.WriteLine(" ║ ║"); + Console.WriteLine(" ║ CoreZipCode Demo Application ║"); + Console.WriteLine(" ║ ║"); + Console.WriteLine(" ╚══════════════════════════════════════════╝"); + Console.WriteLine(); Console.WriteLine(" 1 - ViaCep Service"); Console.WriteLine(" 2 - Smarty Service"); Console.WriteLine(" 3 - PostalpincodeIn Service"); Console.WriteLine(" 4 - PostcodesIo Service"); Console.WriteLine(" 5 - Get out"); - Console.WriteLine("\n"); + Console.WriteLine(""); Console.WriteLine(" Type your choice:"); - switch (Console.ReadLine()) + return Console.ReadLine() switch { - case "1": - ViaCepService(); - return true; - case "2": - SmartyService(); - return true; - case "3": - PostalpincodeInService(); - return true; - case "4": - PostcodesIoService(); - return true; - case "5": - return false; - default: - return true; + "1" => RunAndContinue(ViaCepServiceAsync), + "2" => RunAndContinue(SmartyServiceAsync), + "3" => RunAndContinue(PostalpincodeInServiceAsync), + "4" => RunAndContinue(PostcodesIoServiceAsync), + "5" => false, + _ => true + }; +} + +static void Pause() +{ + Console.Write("\n\nType any key to continue"); + Console.ReadKey(true); +} + +static bool RunAndContinue(Func action) +{ + try + { + action().GetAwaiter().GetResult(); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"Unexpected error: {ex.Message}"); + Pause(); + return true; } } +static async Task HandleResultAsync(Result result, string successMessage, string notFoundMessage) where T : class +{ + if (result.IsSuccess && result.Value != null) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine(successMessage); + Console.ResetColor(); + Console.WriteLine(); + Console.WriteLine(JsonConvert.SerializeObject(result.Value, Formatting.Indented)); + } + else + { + var error = result.IsFailure ? result.Error : null; + + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("Query failed"); + Console.ResetColor(); + + if (error != null) + { + Console.WriteLine($"Status: {error.StatusCode} ({(int)error.StatusCode})"); + Console.WriteLine($"Mensagem: {error.Message}"); + + if (!string.IsNullOrWhiteSpace(error.ResponseBody)) + { + Console.WriteLine("API response:"); + Console.WriteLine(error.ResponseBody.Length > 1000 + ? error.ResponseBody.Substring(0, 1000) + "\n[...truncated]" + : error.ResponseBody); + } + } + else + { + Console.WriteLine(notFoundMessage); + } + } + + Pause(); +} + var showMenu = true; while (showMenu) {