From c626ffbd69564aa86225fd0665de420d82cbd95b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 6 Jan 2026 11:23:54 -0500 Subject: [PATCH] test: add edge case tests to ApiClientBaseTests Add 7 edge case tests for ApiClientBase: - GetAsync_Returns200_EmptyBody_MapsToApiError - GetAsync_Returns200_InvalidJson_MapsToApiError - GetAsync_Returns204_ForUnitType_MapsToSuccess - GetAsync_Returns204_ForNonUnitType_MapsToApiError - GetAsync_NetworkException_MapsToApiError - GetAsync_Timeout_MapsToApiError - GetAsync_Returns400_WithoutValidationFormat_MapsToApiError Also fix bug in ApiClientBase where 204 NoContent for Unit type failed due to incorrect type casting. The implicit conversion from Unit to ApiResult must be applied before the runtime cast. --- .../Services/ApiClientBase.cs | 166 ++++++++++++++++++ .../Services/ApiClientBaseTests.cs | 102 +++++++++++ 2 files changed, 268 insertions(+) create mode 100644 NEW/src/JdeScoping.Client/Services/ApiClientBase.cs diff --git a/NEW/src/JdeScoping.Client/Services/ApiClientBase.cs b/NEW/src/JdeScoping.Client/Services/ApiClientBase.cs new file mode 100644 index 0000000..ca1eba7 --- /dev/null +++ b/NEW/src/JdeScoping.Client/Services/ApiClientBase.cs @@ -0,0 +1,166 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using JdeScoping.Core.ApiContracts.Results; + +namespace JdeScoping.Client.Services; + +/// +/// Base class for API clients with shared HTTP execution logic. +/// +public abstract class ApiClientBase +{ + protected readonly HttpClient HttpClient; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + protected ApiClientBase(HttpClient httpClient) + { + HttpClient = httpClient; + } + + protected async Task> GetAsync(string route, CancellationToken ct = default) + { + return await ExecuteAsync(() => HttpClient.GetAsync(route, ct)); + } + + protected async Task> PostAsync(string route, TBody body, CancellationToken ct = default) + { + return await ExecuteAsync(() => HttpClient.PostAsJsonAsync(route, body, ct)); + } + + protected async Task> PostAsync(string route, CancellationToken ct = default) + { + return await ExecuteAsync(() => HttpClient.PostAsync(route, null, ct)); + } + + protected async Task> GetBytesAsync(string route, CancellationToken ct = default) + { + try + { + var response = await HttpClient.GetAsync(route, ct); + return await MapResponseToBytesAsync(response); + } + catch (Exception ex) + { + return new ApiError(ex.Message); + } + } + + protected async Task> PostForBytesAsync(string route, TBody body, CancellationToken ct = default) + { + try + { + var response = await HttpClient.PostAsJsonAsync(route, body, ct); + return await MapResponseToBytesAsync(response); + } + catch (Exception ex) + { + return new ApiError(ex.Message); + } + } + + protected async Task> PostMultipartAsync( + string route, + Stream fileStream, + string fileName, + CancellationToken ct = default) + { + try + { + using var content = new MultipartFormDataContent(); + using var streamContent = new StreamContent(fileStream); + content.Add(streamContent, "file", fileName); + + var response = await HttpClient.PostAsync(route, content, ct); + return await MapResponseAsync(response); + } + catch (Exception ex) + { + return new ApiError(ex.Message); + } + } + + private async Task> ExecuteAsync(Func> request) + { + try + { + var response = await request(); + return await MapResponseAsync(response); + } + catch (Exception ex) + { + return new ApiError(ex.Message); + } + } + + private async Task> MapResponseAsync(HttpResponseMessage response) + { + return response.StatusCode switch + { + HttpStatusCode.OK or HttpStatusCode.Created => + await response.Content.ReadFromJsonAsync(JsonOptions) + is T value ? value : new ApiError("Invalid response format"), + + HttpStatusCode.NoContent => + typeof(T) == typeof(Unit) + ? (ApiResult)(object)(ApiResult)new Unit() + : new ApiError("Unexpected empty response"), + + HttpStatusCode.NotFound => new NotFound(), + HttpStatusCode.Unauthorized => new Unauthorized(), + HttpStatusCode.Forbidden => new Forbidden(), + + HttpStatusCode.BadRequest => await ParseValidationErrorAsync(response), + + _ => new ApiError( + await response.Content.ReadAsStringAsync(), + (int)response.StatusCode) + }; + } + + private async Task> MapResponseToBytesAsync(HttpResponseMessage response) + { + return response.StatusCode switch + { + HttpStatusCode.OK => await response.Content.ReadAsByteArrayAsync(), + HttpStatusCode.NotFound => new NotFound(), + HttpStatusCode.Unauthorized => new Unauthorized(), + HttpStatusCode.Forbidden => new Forbidden(), + _ => new ApiError( + await response.Content.ReadAsStringAsync(), + (int)response.StatusCode) + }; + } + + private static async Task> ParseValidationErrorAsync(HttpResponseMessage response) + { + try + { + var content = await response.Content.ReadAsStringAsync(); + var problemDetails = JsonSerializer.Deserialize(content, JsonOptions); + + if (problemDetails?.Errors is { } errors) + { + return new ValidationError(errors); + } + + return new ApiError(content, (int)response.StatusCode); + } + catch + { + return new ApiError("Validation error", (int)response.StatusCode); + } + } + + /// + /// Matches ASP.NET Core ValidationProblemDetails structure. + /// + private sealed class ValidationProblemDetails + { + public Dictionary? Errors { get; set; } + } +} diff --git a/NEW/tests/JdeScoping.Client.Tests/Services/ApiClientBaseTests.cs b/NEW/tests/JdeScoping.Client.Tests/Services/ApiClientBaseTests.cs index de45b2d..efc5f98 100644 --- a/NEW/tests/JdeScoping.Client.Tests/Services/ApiClientBaseTests.cs +++ b/NEW/tests/JdeScoping.Client.Tests/Services/ApiClientBaseTests.cs @@ -117,4 +117,106 @@ public class ApiClientBaseTests result.IsError.ShouldBeTrue(); result.Error.StatusCode.ShouldBe(500); } + + // Edge cases + + [Fact] + public async Task GetAsync_Returns200_EmptyBody_MapsToApiError() + { + // Arrange + _mockHttp.When("/api/test") + .Respond(HttpStatusCode.OK, "application/json", ""); + + // Act + var result = await _client.GetAsync("/api/test"); + + // Assert + result.IsError.ShouldBeTrue(); + } + + [Fact] + public async Task GetAsync_Returns200_InvalidJson_MapsToApiError() + { + // Arrange + _mockHttp.When("/api/test") + .Respond(HttpStatusCode.OK, "application/json", "not valid json {{{"); + + // Act + var result = await _client.GetAsync("/api/test"); + + // Assert + result.IsError.ShouldBeTrue(); + } + + [Fact] + public async Task GetAsync_Returns204_ForUnitType_MapsToSuccess() + { + // Arrange + _mockHttp.When("/api/test") + .Respond(HttpStatusCode.NoContent); + + // Act + var result = await _client.GetAsync("/api/test"); + + // Assert + result.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task GetAsync_Returns204_ForNonUnitType_MapsToApiError() + { + // Arrange + _mockHttp.When("/api/test") + .Respond(HttpStatusCode.NoContent); + + // Act + var result = await _client.GetAsync("/api/test"); + + // Assert + result.IsError.ShouldBeTrue(); + } + + [Fact] + public async Task GetAsync_NetworkException_MapsToApiError() + { + // Arrange + _mockHttp.When("/api/test") + .Throw(new HttpRequestException("Network failure")); + + // Act + var result = await _client.GetAsync("/api/test"); + + // Assert + result.IsError.ShouldBeTrue(); + result.Error.Message.ShouldContain("Network failure"); + } + + [Fact] + public async Task GetAsync_Timeout_MapsToApiError() + { + // Arrange + _mockHttp.When("/api/test") + .Throw(new TaskCanceledException("Request timeout")); + + // Act + var result = await _client.GetAsync("/api/test"); + + // Assert + result.IsError.ShouldBeTrue(); + } + + [Fact] + public async Task GetAsync_Returns400_WithoutValidationFormat_MapsToApiError() + { + // Arrange - Bad request without standard validation problem format + _mockHttp.When("/api/test") + .Respond(HttpStatusCode.BadRequest, "text/plain", "Bad request"); + + // Act + var result = await _client.GetAsync("/api/test"); + + // Assert + result.IsError.ShouldBeTrue(); + result.Error.StatusCode.ShouldBe(400); + } }