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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user