feat: add health check endpoint, file upload result handling, and Playwright E2E tests
- Add /health endpoint with anonymous access for monitoring - Add FileUploadResult<T> model and PostMultipartForFileResultAsync for proper upload response handling - Add ApiResult.Success() factory method for interface types - Refactor Login.razor for cleaner code - Add comprehensive Playwright E2E test suite with fixtures and helpers
This commit is contained in:
@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.OpenApi.Models;
|
||||
|
||||
namespace Microsoft.Extensions.DependencyInjection;
|
||||
@@ -113,6 +114,12 @@ public static class ApiDependencyInjection
|
||||
app.MapControllers();
|
||||
app.MapHub<StatusHub>("/hubs/status");
|
||||
|
||||
// Health check endpoint - no authentication required
|
||||
app.MapHealthChecks("/health", new HealthCheckOptions
|
||||
{
|
||||
AllowCachingResponses = false
|
||||
}).AllowAnonymous();
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace JdeScoping.Client.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a file upload operation from the API.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of data parsed from the uploaded file.</typeparam>
|
||||
public class FileUploadResult<T>
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the upload was successful.
|
||||
/// </summary>
|
||||
public bool WasSuccessful { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if the upload failed.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Parsed data from the uploaded file.
|
||||
/// </summary>
|
||||
public T[]? Data { get; set; }
|
||||
}
|
||||
@@ -75,43 +75,44 @@
|
||||
var request = new EncryptedLoginRequest(encryptedData);
|
||||
|
||||
var result = await AuthApi.LoginAsync(request);
|
||||
result.Switch(
|
||||
loginResult =>
|
||||
{
|
||||
if (loginResult.Success && loginResult.User is not null)
|
||||
{
|
||||
// Fire-and-forget with error handling to prevent silent failures
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await AuthStateProvider.MarkUserAsAuthenticated(loginResult.User);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Failed to mark user as authenticated: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
var returnUrl = string.IsNullOrEmpty(ReturnUrl) ? "/" : ReturnUrl;
|
||||
NavigationManager.NavigateTo(returnUrl);
|
||||
}
|
||||
else
|
||||
{
|
||||
_errorMessage = loginResult.ErrorMessage ?? "Login failed. Please check your credentials.";
|
||||
}
|
||||
},
|
||||
notFound => { _errorMessage = "Authentication service not found."; },
|
||||
validation =>
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
var loginResult = result.Value;
|
||||
if (loginResult.Success && loginResult.User is not null)
|
||||
{
|
||||
// ValidationError has FieldErrors dictionary, not Message property
|
||||
var errors = validation.FieldErrors.SelectMany(e => e.Value);
|
||||
_errorMessage = string.Join(", ", errors);
|
||||
},
|
||||
unauthorized => { _errorMessage = "Invalid credentials."; },
|
||||
forbidden => { _errorMessage = "Access denied."; },
|
||||
error => { _errorMessage = error.Message; }
|
||||
);
|
||||
// Await auth state update before navigating to prevent race condition
|
||||
await AuthStateProvider.MarkUserAsAuthenticated(loginResult.User);
|
||||
|
||||
var returnUrl = string.IsNullOrEmpty(ReturnUrl) ? "/" : ReturnUrl;
|
||||
NavigationManager.NavigateTo(returnUrl);
|
||||
}
|
||||
else
|
||||
{
|
||||
_errorMessage = loginResult.ErrorMessage ?? "Login failed. Please check your credentials.";
|
||||
}
|
||||
}
|
||||
else if (result.IsNotFound)
|
||||
{
|
||||
_errorMessage = "Authentication service not found.";
|
||||
}
|
||||
else if (result.IsValidationError)
|
||||
{
|
||||
var errors = result.ValidationError.FieldErrors.SelectMany(e => e.Value);
|
||||
_errorMessage = string.Join(", ", errors);
|
||||
}
|
||||
else if (result.IsUnauthorized)
|
||||
{
|
||||
_errorMessage = "Invalid credentials.";
|
||||
}
|
||||
else if (result.IsForbidden)
|
||||
{
|
||||
_errorMessage = "Access denied.";
|
||||
}
|
||||
else if (result.IsError)
|
||||
{
|
||||
_errorMessage = result.Error.Message;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using JdeScoping.Client.Models;
|
||||
using JdeScoping.Core.ApiContracts.Results;
|
||||
|
||||
namespace JdeScoping.Client.Services;
|
||||
@@ -128,6 +129,61 @@ public abstract class ApiClientBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a multipart POST request for file upload endpoints that return FileUploadResult<T>.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The element type within the result array.</typeparam>
|
||||
/// <param name="route">The API route.</param>
|
||||
/// <param name="fileStream">The file stream to upload.</param>
|
||||
/// <param name="fileName">The file name for the multipart form.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
protected async Task<ApiResult<IReadOnlyList<T>>> PostMultipartForFileResultAsync<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 MapFileUploadResponseAsync<T>(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ApiError(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ApiResult<IReadOnlyList<T>>> MapFileUploadResponseAsync<T>(HttpResponseMessage response)
|
||||
{
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<FileUploadResult<T>>(JsonOptions);
|
||||
if (result is null)
|
||||
return new ApiError("Invalid response format");
|
||||
if (!result.WasSuccessful)
|
||||
return new ApiError(result.ErrorMessage ?? "Upload failed");
|
||||
IReadOnlyList<T> data = result.Data ?? Array.Empty<T>();
|
||||
return ApiResult<IReadOnlyList<T>>.Success(data);
|
||||
}
|
||||
|
||||
// Delegate other status codes to existing error handling
|
||||
return response.StatusCode switch
|
||||
{
|
||||
HttpStatusCode.NotFound => new NotFound(),
|
||||
HttpStatusCode.Unauthorized => new Unauthorized(),
|
||||
HttpStatusCode.Forbidden => new Forbidden(),
|
||||
HttpStatusCode.BadRequest => await ParseValidationErrorAsync<IReadOnlyList<T>>(response),
|
||||
_ => new ApiError(
|
||||
await response.Content.ReadAsStringAsync(),
|
||||
(int)response.StatusCode)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ApiResult<T>> ExecuteAsync<T>(Func<Task<HttpResponseMessage>> request)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -63,7 +63,7 @@ public class FileApiClient : ApiClientBase, IFileApiClient
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of parsed work order view models.</returns>
|
||||
public Task<ApiResult<IReadOnlyList<WorkOrderViewModel>>> UploadWorkOrdersAsync(Stream fileStream, string fileName, CancellationToken ct = default)
|
||||
=> PostMultipartAsync<IReadOnlyList<WorkOrderViewModel>>(ApiRoutes.FileIO.UploadWorkOrders, fileStream, fileName, ct);
|
||||
=> PostMultipartForFileResultAsync<WorkOrderViewModel>(ApiRoutes.FileIO.UploadWorkOrders, fileStream, fileName, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Uploads and parses an Excel file containing items.
|
||||
@@ -73,7 +73,7 @@ public class FileApiClient : ApiClientBase, IFileApiClient
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of parsed item view models.</returns>
|
||||
public Task<ApiResult<IReadOnlyList<ItemViewModel>>> UploadItemsAsync(Stream fileStream, string fileName, CancellationToken ct = default)
|
||||
=> PostMultipartAsync<IReadOnlyList<ItemViewModel>>(ApiRoutes.FileIO.UploadItems, fileStream, fileName, ct);
|
||||
=> PostMultipartForFileResultAsync<ItemViewModel>(ApiRoutes.FileIO.UploadItems, fileStream, fileName, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Uploads and parses an Excel file containing component lots.
|
||||
@@ -83,7 +83,7 @@ public class FileApiClient : ApiClientBase, IFileApiClient
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of parsed lot view models.</returns>
|
||||
public Task<ApiResult<IReadOnlyList<LotViewModel>>> UploadComponentLotsAsync(Stream fileStream, string fileName, CancellationToken ct = default)
|
||||
=> PostMultipartAsync<IReadOnlyList<LotViewModel>>(ApiRoutes.FileIO.UploadComponentLots, fileStream, fileName, ct);
|
||||
=> PostMultipartForFileResultAsync<LotViewModel>(ApiRoutes.FileIO.UploadComponentLots, fileStream, fileName, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Uploads and parses an Excel file containing part operations.
|
||||
@@ -93,5 +93,5 @@ public class FileApiClient : ApiClientBase, IFileApiClient
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>List of parsed part operation view models.</returns>
|
||||
public Task<ApiResult<IReadOnlyList<PartOperationViewModel>>> UploadPartOperationsAsync(Stream fileStream, string fileName, CancellationToken ct = default)
|
||||
=> PostMultipartAsync<IReadOnlyList<PartOperationViewModel>>(ApiRoutes.FileIO.UploadPartOperations, fileStream, fileName, ct);
|
||||
=> PostMultipartForFileResultAsync<PartOperationViewModel>(ApiRoutes.FileIO.UploadPartOperations, fileStream, fileName, ct);
|
||||
}
|
||||
|
||||
@@ -36,4 +36,12 @@ public partial class ApiResult<T> : OneOfBase<T, NotFound, ValidationError, Unau
|
||||
|
||||
/// <summary>Gets the API error. Throws if not an API error.</summary>
|
||||
public ApiError Error => AsT5;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a success result from the given value.
|
||||
/// Useful when implicit conversion doesn't work (e.g., for interface types).
|
||||
/// </summary>
|
||||
/// <param name="value">The success value.</param>
|
||||
/// <returns>An ApiResult containing the success value.</returns>
|
||||
public static ApiResult<T> Success(T value) => value;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user