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:
Joseph Doherty
2026-01-30 07:12:20 -05:00
parent ae69a261d6
commit ee044d03e0
59 changed files with 11740 additions and 49 deletions
@@ -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; }
}
+36 -35
View File
@@ -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&lt;T&gt;.
/// </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;
}