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<Unit> must be applied before the runtime cast.
This commit is contained in:
Joseph Doherty
2026-01-06 11:23:54 -05:00
parent 6af5a4f9d6
commit c626ffbd69
2 changed files with 268 additions and 0 deletions
@@ -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;
/// <summary>
/// Base class for API clients with shared HTTP execution logic.
/// </summary>
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<ApiResult<T>> GetAsync<T>(string route, CancellationToken ct = default)
{
return await ExecuteAsync<T>(() => HttpClient.GetAsync(route, ct));
}
protected async Task<ApiResult<T>> PostAsync<T, TBody>(string route, TBody body, CancellationToken ct = default)
{
return await ExecuteAsync<T>(() => HttpClient.PostAsJsonAsync(route, body, ct));
}
protected async Task<ApiResult<T>> PostAsync<T>(string route, CancellationToken ct = default)
{
return await ExecuteAsync<T>(() => HttpClient.PostAsync(route, null, ct));
}
protected async Task<ApiResult<byte[]>> 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<ApiResult<byte[]>> PostForBytesAsync<TBody>(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<ApiResult<T>> PostMultipartAsync<T>(
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<T>(response);
}
catch (Exception ex)
{
return new ApiError(ex.Message);
}
}
private async Task<ApiResult<T>> ExecuteAsync<T>(Func<Task<HttpResponseMessage>> request)
{
try
{
var response = await request();
return await MapResponseAsync<T>(response);
}
catch (Exception ex)
{
return new ApiError(ex.Message);
}
}
private async Task<ApiResult<T>> MapResponseAsync<T>(HttpResponseMessage response)
{
return response.StatusCode switch
{
HttpStatusCode.OK or HttpStatusCode.Created =>
await response.Content.ReadFromJsonAsync<T>(JsonOptions)
is T value ? value : new ApiError("Invalid response format"),
HttpStatusCode.NoContent =>
typeof(T) == typeof(Unit)
? (ApiResult<T>)(object)(ApiResult<Unit>)new Unit()
: new ApiError("Unexpected empty response"),
HttpStatusCode.NotFound => new NotFound(),
HttpStatusCode.Unauthorized => new Unauthorized(),
HttpStatusCode.Forbidden => new Forbidden(),
HttpStatusCode.BadRequest => await ParseValidationErrorAsync<T>(response),
_ => new ApiError(
await response.Content.ReadAsStringAsync(),
(int)response.StatusCode)
};
}
private async Task<ApiResult<byte[]>> 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<ApiResult<T>> ParseValidationErrorAsync<T>(HttpResponseMessage response)
{
try
{
var content = await response.Content.ReadAsStringAsync();
var problemDetails = JsonSerializer.Deserialize<ValidationProblemDetails>(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);
}
}
/// <summary>
/// Matches ASP.NET Core ValidationProblemDetails structure.
/// </summary>
private sealed class ValidationProblemDetails
{
public Dictionary<string, string[]>? Errors { get; set; }
}
}
@@ -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<TestDto>("/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<TestDto>("/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<Unit>("/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<TestDto>("/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<TestDto>("/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<TestDto>("/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<TestDto>("/api/test");
// Assert
result.IsError.ShouldBeTrue();
result.Error.StatusCode.ShouldBe(400);
}
}