Compare commits

...

21 Commits

Author SHA1 Message Date
e5295b8302 Add global exception handling middleware
Introduced ExceptionHandlingMiddleware to handle exceptions across the ASP.NET Core request pipeline. The middleware logs exceptions and returns JSON error responses with appropriate HTTP status codes for BadRequestException (400), NotFoundException (404), and generic errors (500). Dependency injection is used for RequestDelegate and ILogger.
2026-04-09 14:47:46 +02:00
00a9cf06da Refactor envelope doc query and improve result validation
Refactored ReadSingleEnvelopeDocResultQuery to remove inheritance from EnvelopeQueryBase and introduce an Envelope property. Enhanced the handler to ensure DocResult is a non-empty byte array before returning, throwing NotFoundException otherwise.
2026-04-09 14:46:04 +02:00
1b387238e8 Configure EnvelopeReport as a keyless entity
Added Entity Framework model configuration for EnvelopeReport in EGDbContextBase, specifying it as a keyless entity using HasNoKey(). This allows EnvelopeReport to be used without a primary key in the database context.
2026-04-09 14:30:23 +02:00
bda4f3dbef Add conditional Windows Service & Kestrel config support
The app now checks configuration values to optionally run as a Windows Service ("UseWindowsService") and/or apply custom Kestrel server settings from the "Kestrel" config section ("UseKestrelConfig"). These changes improve deployment flexibility.
2026-04-09 14:20:04 +02:00
2458d0c07a Configure Kestrel endpoint and hosting options in settings
Added "UseWindowsService" and "UseKestrelConfig" flags to appsettings.json. Defined a custom Kestrel HTTP endpoint at http://localhost:1111 to control how the application is hosted and served.
2026-04-09 14:19:36 +02:00
a72cbab195 Add GET endpoint to return envelope PDF by UUID
Added a new HTTP GET action to DocResultController that accepts a ReadSingleEnvelopeDocResultQuery via query string. The endpoint uses MediatR to retrieve the PDF document and returns it as a file response with the envelope's UUID in the filename and the correct content type.
2026-04-09 14:05:54 +02:00
bcf4e63f7c Add distributed cache, localization, and infra services
- Add DigitalData.Core.API and SQL Server distributed cache dependencies
- Register EnvelopeGenerator.Application project reference
- Configure distributed SQL Server cache and memory cache
- Register infrastructure, application, and user management services
- Set up EF Core with SQL Server and detailed logging
- Enable localization with configurable supported cultures
- Improve modularity and extensibility of service registration
2026-04-09 13:36:40 +02:00
5aabeb4510 Update project to net8.0-windows and enable WinForms
Changed target framework to net8.0-windows to specify Windows platform support. Enabled Windows Forms by setting UseWindowsForms to true in the project file.
2026-04-09 13:34:07 +02:00
32edc6474d Add SupportedCultures and DB connection to appsettings.json
Added "SupportedCultures" for localization support and a "ConnectionStrings" section with a default SQL Server connection string. Also reformatted "AllowedHosts" for consistency.
2026-04-09 13:22:59 +02:00
71bfe3b323 Refactor Worker to resolve FinalizeDocumentJob per scope
Refactored Worker to use IServiceScopeFactory instead of directly injecting FinalizeDocumentJob. Now, a new scope is created in each loop iteration, and FinalizeDocumentJob is resolved from the scoped service provider. This enables FinalizeDocumentJob to use scoped dependencies and improves DI flexibility.
2026-04-09 13:20:02 +02:00
089d2bd1cb Add FinalizeDocumentController and refactor query model
Refactored ReadSingleEnvelopeDocResultQuery to use a parameterless constructor and an Envelope property. Introduced FinalizeDocumentController with a GET endpoint to finalize and return envelope documents as PDFs, supporting force regeneration. Added dependency injection for IMediator and FinalizeDocumentJob. Includes a TODO to migrate forceRegenerate logic into the job.
2026-04-09 10:55:13 +02:00
65c72bcf77 Add DocResultController for envelope PDF download
Introduced DocResultController with a GET endpoint to retrieve envelope PDF documents by sending a query via MediatR. The controller returns the PDF as a file response with an appropriate filename and content type. Added necessary using directives for MediatR, ASP.NET Core MVC, and the application query.
2026-04-09 10:30:04 +02:00
2d8375f26a Refactor envelope query to throw on not found or multiple
Refactored ReadSingleEnvelopeQuery and its handler to return EnvelopeDto directly and throw NotFoundException or BadRequestException when no or multiple envelopes are found, instead of returning null. Updated imports to include custom exceptions.
2026-04-09 10:28:13 +02:00
a7cfb099fa Add query/handler for envelope document retrieval
Introduced ReadSingleEnvelopeDocResultQuery and its handler to fetch an envelope's document as a byte array via MediatR. Throws NotFoundException if the document is missing. Includes XML documentation for clarity.
2026-04-09 10:26:44 +02:00
7a0d4e2fa7 Remove MediatorGetOrContext; add ExecuteAsync overloads
Removed MediatorGetOrContext.cs, eliminating the fluent API for handling null or empty MediatR responses with custom exceptions. Added two ExecuteAsync overloads to FinalizeDocumentJob: one for processing a single EnvelopeDto and another for processing all envelopes with the EnvelopeCompletelySigned status.
2026-04-09 10:25:59 +02:00
3955ee9f39 Refactor Mediator GetOr API for naming consistency
Renamed MediatorExtensions to MediatorGetOrContext and GetOrContext<TResponse> to MediatorGetOrContext<TResponse> for consistent naming. Moved the GetOr extension method into the new static class. Updated XML docs and reorganized declarations; no functional changes.
2026-04-08 15:34:39 +02:00
9bdf24d7d5 Refactor MediatorExtensions to fluent GetOr/Throw API
Replaced GetOrThrow methods with a fluent GetOr/Throw pattern for handling null or empty MediatR responses. Introduced GetOrContext<TResponse> struct with Throw, ThrowNotFound, ThrowInvalidOperation, and ThrowBadRequest methods. Updated all tests to use the new API and added coverage for new exception types. Improved XML docs and performed minor code cleanup.
2026-04-08 15:26:23 +02:00
993ca82596 Rename MediatR extensions to GetOrThrow for ISender
Renamed SendOrThrowAsync and SendOrNotFoundAsync extension methods for IMediator to GetOrThrow for ISender, following MediatR best practices. Updated all usages, XML docs, and tests to use ISender and the new method names. Replaced StubMediator with StubSender in tests. Functionality remains the same, but code now aligns with modern MediatR conventions.
2026-04-08 14:01:24 +02:00
ce9958a8b1 Add MediatorExtensions tests and refactor CreateEnvelopeCommand
Introduce MediatorExtensionsTests to cover SendOrThrowAsync and SendOrNotFoundAsync extension methods for IMediator, including edge cases and cancellation. Refactor CreateEnvelopeCommand in Fake.cs to use Authorize(userId) instead of setting UserId directly. Add test stubs for IMediator and IRequest to support isolated testing.
2026-04-08 13:46:27 +02:00
6c54473d5a Refactor MediatorExtensions for flexible exception handling
Generalize null/empty response handling with SendOrThrowAsync<TResponse, TException>, allowing custom exceptions via a factory delegate. SendOrNotFoundAsync now wraps this method for NotFoundException. Improves type safety, flexibility, and XML docs; avoids treating strings as collections.
2026-04-08 13:44:19 +02:00
9ad4352e02 Add MediatorExtensions for not-found handling in MediatR
Introduced MediatorExtensions with SendOrNotFoundAsync methods to enforce non-null and non-empty responses from MediatR requests. These extensions throw NotFoundException when responses are null or empty, centralizing not-found logic and improving error handling.
2026-04-08 13:25:34 +02:00
13 changed files with 599 additions and 25 deletions

View File

@@ -0,0 +1,47 @@
using MediatR;
using EnvelopeGenerator.Application.Common.Dto;
using DigitalData.Core.Exceptions;
namespace EnvelopeGenerator.Application.Envelopes.Queries;
/// <summary>
/// Repräsentiert eine Abfrage für Umschläge.
/// </summary>
public record ReadSingleEnvelopeDocResultQuery() : IRequest<byte[]>
{
/// <summary>
///
/// </summary>
public ReadSingleEnvelopeQuery Envelope { get; set; } = null!;
}
/// <summary>
/// Verarbeitet <see cref="ReadEnvelopeQuery"/> und liefert passende <see cref="EnvelopeDto"/>-Ergebnisse.
/// </summary>
public class ReadSingleEnvelopeDocResultQueryHandler : IRequestHandler<ReadSingleEnvelopeDocResultQuery, byte[]>
{
private readonly IMediator _mediator;
/// <summary>
///
/// </summary>
/// <param name="mediator"></param>
public ReadSingleEnvelopeDocResultQueryHandler(IMediator mediator)
{
_mediator = mediator;
}
/// <summary>
///
/// </summary>
/// <param name="request"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task<byte[]> Handle(ReadSingleEnvelopeDocResultQuery request, CancellationToken cancellationToken)
{
var result = await _mediator.Send(request.Envelope, cancellationToken);
return result.DocResult is byte[] docResult && docResult.Length > 0
? docResult
: throw new NotFoundException($"Document for Envelope with ID {request.Envelope.Id} not found");
}
}

View File

@@ -5,13 +5,14 @@ using AutoMapper;
using DigitalData.Core.Abstraction.Application.Repository;
using EnvelopeGenerator.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using DigitalData.Core.Exceptions;
namespace EnvelopeGenerator.Application.Envelopes.Queries;
/// <summary>
/// Repräsentiert eine Abfrage für einen einzelnen Umschlag.
/// </summary>
public record ReadSingleEnvelopeQuery : EnvelopeQueryBase, IRequest<EnvelopeDto?>
public record ReadSingleEnvelopeQuery : EnvelopeQueryBase, IRequest<EnvelopeDto>
{
/// <summary>
/// Optionaler Benutzerfilter; wenn gesetzt, werden nur Umschläge des Benutzers geladen.
@@ -27,7 +28,7 @@ public record ReadSingleEnvelopeQuery : EnvelopeQueryBase, IRequest<EnvelopeDto?
/// <summary>
/// Verarbeitet <see cref="ReadSingleEnvelopeQuery"/> und liefert ein einzelnes <see cref="EnvelopeDto"/>-Ergebnis.
/// </summary>
public class ReadSingleEnvelopeQueryHandler : IRequestHandler<ReadSingleEnvelopeQuery, EnvelopeDto?>
public class ReadSingleEnvelopeQueryHandler : IRequestHandler<ReadSingleEnvelopeQuery, EnvelopeDto>
{
private readonly IRepository<Envelope> _repository;
private readonly IMapper _mapper;
@@ -49,7 +50,7 @@ public class ReadSingleEnvelopeQueryHandler : IRequestHandler<ReadSingleEnvelope
/// <param name="request"></param>
/// <param name="cancel"></param>
/// <returns></returns>
public async Task<EnvelopeDto?> Handle(ReadSingleEnvelopeQuery request, CancellationToken cancel)
public async Task<EnvelopeDto> Handle(ReadSingleEnvelopeQuery request, CancellationToken cancel)
{
var query = _repository.Query;
@@ -61,11 +62,17 @@ public class ReadSingleEnvelopeQueryHandler : IRequestHandler<ReadSingleEnvelope
if (request.Uuid is string uuid)
query = query.Where(e => e.Uuid == uuid);
var envelope = await query
var envelopes = await query
.Include(e => e.Documents)
.FirstOrDefaultAsync(cancel);
.Take(2)
.ToListAsync(cancel);
return envelope is null ? null : _mapper.Map<EnvelopeDto>(envelope);
if (envelopes.Count > 1)
throw new BadRequestException($"Multiple envelopes found for the given criteria: Id={request.Id}, Uuid={request.Uuid}, UserId={request.UserId}");
return envelopes.SingleOrDefault() is Envelope envelope
? _mapper.Map<EnvelopeDto>(envelope)
: throw new NotFoundException($"Envelope with Id={request.Id}, Uuid={request.Uuid} not found");
}
}
}

View File

@@ -202,6 +202,10 @@ public abstract class EGDbContextBase : DbContext
.HasForeignKey(annot => annot.ElementId);
#endregion
#region EnvelopeReport
modelBuilder.Entity<EnvelopeReport>().HasNoKey();
#endregion
#region Trigger
// Configure entities to handle database triggers
void AddTrigger<T>() where T : class => _triggers

View File

@@ -0,0 +1,16 @@
using EnvelopeGenerator.Application.Envelopes.Queries;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.ServiceHost.Controllers;
[Route("api/[controller]")]
[ApiController]
public class DocResultController(IMediator mediator) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> GetAsync([FromQuery] ReadSingleEnvelopeDocResultQuery query, CancellationToken cancel = default)
{
return File(await mediator.Send(query, cancel), "application/pdf", $"envelope_{query.Envelope.Uuid}.pdf");
}
}

View File

@@ -0,0 +1,29 @@
using EnvelopeGenerator.Application.Envelopes.Queries;
using EnvelopeGenerator.ServiceHost.Jobs;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.ServiceHost.Controllers;
/// <summary>
///
/// </summary>
/// <param name="mediator"></param>
/// <param name="job"></param>
[Route("api/[controller]")]
[ApiController]
public class FinalizeDocumentController(IMediator mediator, FinalizeDocumentJob job) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> FinalizeEnvelopeDocument([FromQuery] ReadSingleEnvelopeQuery query, [FromQuery] bool forceRegenerate = false, CancellationToken cancel = default)
{
var envelope = await mediator.Send(query, cancel);
// TODO: migrate forceRegenerate input to FinalizeDocumentJob as property and remove this check from controller
if (envelope.DocResult is null || forceRegenerate)
await job.ExecuteAsync(envelope, cancel);
var docResult = await mediator.Send(new ReadSingleEnvelopeDocResultQuery() { Envelope = query }, cancel);
return File(docResult, "application/pdf", $"envelope_{query.Uuid}.pdf");
}
}

View File

@@ -1,12 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DigitalData.Core.API" Version="2.2.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="GdPicture" Version="14.3.3" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
@@ -18,9 +20,11 @@
<PackageReference Include="System.Drawing.Common" Version="8.0.16" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.2" />
<PackageReference Include="DevExpress.Reporting.Core" Version="24.2.*" />
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="8.0.17" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
<ProjectReference Include="..\EnvelopeGenerator.Domain\EnvelopeGenerator.Domain.csproj" />
<ProjectReference Include="..\EnvelopeGenerator.Infrastructure\EnvelopeGenerator.Infrastructure.csproj" />
<ProjectReference Include="..\EnvelopeGenerator.PdfEditor\EnvelopeGenerator.PdfEditor.csproj" />

View File

@@ -60,6 +60,8 @@ public class FinalizeDocumentJob(IOptions<WorkerOptions> options, ILogger<Finali
}
}
public Task ExecuteAsync(EnvelopeDto envelope, CancellationToken cancel = default) => ExecuteAsync([envelope], cancel);
public async Task ExecuteAsync(CancellationToken cancel = default)
{
var envelopes = await mediator.Send(new ReadEnvelopeQuery()

View File

@@ -0,0 +1,75 @@
namespace EnvelopeGenerator.ServiceHost.Middleware;
using DigitalData.Core.Exceptions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System.Net;
using System.Text.Json;
/// <summary>
/// Middleware for handling exceptions globally in the application.
/// Captures exceptions thrown during the request pipeline execution,
/// logs them, and returns an appropriate HTTP response with a JSON error message.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="ExceptionHandlingMiddleware"/> class.
/// </remarks>
/// <param name="next">The next middleware in the request pipeline.</param>
/// <param name="logger">The logger instance for logging exceptions.</param>
public class ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
{
/// <summary>
/// Invokes the middleware to handle the HTTP request.
/// </summary>
/// <param name="context">The HTTP context of the current request.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InvokeAsync(HttpContext context)
{
try
{
await next(context); // Continue down the pipeline
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex, logger);
}
}
/// <summary>
/// Handles exceptions by logging them and writing an appropriate JSON response.
/// </summary>
/// <param name="context">The HTTP context of the current request.</param>
/// <param name="exception">The exception that occurred.</param>
/// <param name="logger">The logger instance for logging the exception.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
private static async Task HandleExceptionAsync(HttpContext context, Exception exception, ILogger logger)
{
context.Response.ContentType = "application/json";
string message;
switch (exception)
{
case BadRequestException badRequestEx:
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
message = badRequestEx.Message;
break;
case NotFoundException notFoundEx:
context.Response.StatusCode = (int)HttpStatusCode.NotFound;
message = notFoundEx.Message;
break;
default:
logger.LogError(exception, "Unhandled exception occurred.");
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
message = "An unexpected error occurred.";
break;
}
await context.Response.WriteAsync(JsonSerializer.Serialize(new
{
message
}));
}
}

View File

@@ -1,15 +1,65 @@
using DigitalData.UserManager.DependencyInjection;
using EnvelopeGenerator.Application;
using EnvelopeGenerator.Infrastructure;
using EnvelopeGenerator.ServiceHost;
using EnvelopeGenerator.ServiceHost.Extensions;
using Microsoft.AspNetCore.Localization;
using Microsoft.EntityFrameworkCore;
using System.Globalization;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
#region Kestrel & Windows Service Configuration
if (builder.Configuration.GetValue<bool>("UseWindowsService"))
{
builder.Host.UseWindowsService();
}
if (builder.Configuration.GetValue<bool>("UseKestrelConfig"))
{
builder.WebHost.ConfigureKestrel((context, serverOptions) =>
{
var kestrelSection = context.Configuration.GetSection("Kestrel");
serverOptions.Configure(kestrelSection);
});
}
#endregion
var config = builder.Configuration;
var connStr = config.GetConnectionString("Default") ??
throw new InvalidOperationException("Connection string 'Default' is missing in the configuration.");
#region Service configuration
builder.Services.AddControllers();
builder.Services.AddFinalizeDocumentJob(builder.Configuration);
builder.Services.AddHostedService<Worker>();
builder.Services.AddDistributedSqlServerCache(options =>
{
options.ConnectionString = connStr;
options.SchemaName = "dbo";
options.TableName = "TBDD_CACHE";
});
#pragma warning disable CS0618
builder.Services.AddFinalizeDocumentJob(config);
builder.Services.AddEnvelopeGeneratorInfrastructureServices(
opt =>
{
opt.AddDbTriggerParams(config);
opt.AddDbContext((provider, options) =>
{
var logger = provider.GetRequiredService<ILogger<EGDbContext>>();
options.UseSqlServer(connStr)
.LogTo(log => logger.LogInformation("{log}", log), Microsoft.Extensions.Logging.LogLevel.Trace)
.EnableSensitiveDataLogging()
.EnableDetailedErrors();
});
});
builder.Services.AddEnvelopeGeneratorServices(config);
builder.Services.AddMemoryCache();
builder.Services.AddUserManager<EGDbContext>();
#pragma warning restore CS0618
builder.Services.AddLocalization();
#endregion
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
@@ -22,10 +72,22 @@ if (app.Environment.IsDevelopment())
app.UseSwaggerUI();
}
#region Localizer
var supportedCultureNames = config.GetSection("SupportedCultures").Get<string[]>() ?? ["de-DE", "en-US"];
var supportedCultures = supportedCultureNames.Select(cName => new CultureInfo(cName)).ToList();
var requestLocalizationOptions = new RequestLocalizationOptions
{
SupportedCultures = supportedCultures,
SupportedUICultures = supportedCultures
};
requestLocalizationOptions.RequestCultureProviders.Add(new QueryStringRequestCultureProvider());
app.UseRequestLocalization(requestLocalizationOptions);
#endregion
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
app.Run();

View File

@@ -6,13 +6,13 @@ public class Worker : BackgroundService
{
private readonly ILogger<Worker> _logger;
private readonly int _delayMilliseconds;
private readonly FinalizeDocumentJob _finalizeDocumentJob;
private readonly IServiceScopeFactory _scopeFactory;
public Worker(ILogger<Worker> logger, IConfiguration configuration, FinalizeDocumentJob finalizeDocumentJob)
public Worker(ILogger<Worker> logger, IConfiguration configuration, IServiceScopeFactory scopeFactory)
{
_logger = logger;
_delayMilliseconds = Math.Max(1, configuration.GetValue("Worker:DelayMilliseconds", 1000));
_finalizeDocumentJob = finalizeDocumentJob;
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -24,7 +24,10 @@ public class Worker : BackgroundService
_logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
}
await _finalizeDocumentJob.ExecuteAsync(stoppingToken);
using var scope = _scopeFactory.CreateScope();
var finalizeDocumentJob = scope.ServiceProvider.GetRequiredService<FinalizeDocumentJob>();
await finalizeDocumentJob.ExecuteAsync(stoppingToken);
await Task.Delay(_delayMilliseconds, stoppingToken);
}
}

View File

@@ -8,5 +8,18 @@
"Worker": {
"DelayMilliseconds": 1000
},
"AllowedHosts": "*"
}
"AllowedHosts": "*",
"SupportedCultures": [ "de-DE", "en-US" ],
"ConnectionStrings": {
"Default": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;"
},
"UseWindowsService": false,
"UseKestrelConfig": false,
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:1111"
}
}
}
}

View File

@@ -193,13 +193,17 @@ public static class Extensions
#endregion
#region Envelope
public static CreateEnvelopeCommand CreateEnvelopeCommand(this Faker fake, int userId) => new()
public static CreateEnvelopeCommand CreateEnvelopeCommand(this Faker fake, int userId)
{
Message = fake.Lorem.Paragraph(fake.Random.Number(2, 5)),
Title = fake.Lorem.Words(fake.Random.Number(3, 4)).Join(" "),
UserId = userId,
UseSQLExecutor = false
};
var cmd = new CreateEnvelopeCommand
{
Message = fake.Lorem.Paragraph(fake.Random.Number(2, 5)),
Title = fake.Lorem.Words(fake.Random.Number(3, 4)).Join(" "),
UseSQLExecutor = false
};
cmd.Authorize(userId);
return cmd;
}
public static List<CreateEnvelopeCommand> CreateEnvelopeCommands(this Faker fake, params int[] userIDs)
=> Enumerable.Range(0, userIDs.Length)

View File

@@ -0,0 +1,308 @@
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Application.Common.Extensions;
using MediatR;
namespace EnvelopeGenerator.Tests.Application;
[TestFixture]
public class MediatorExtensionsTests
{
#region Stub infrastructure
private sealed class StubRequest<TResponse> : IRequest<TResponse?> { }
/// <summary>
/// Minimal <see cref="ISender"/> stub that returns a pre-configured response for any <see cref="ISender.Send{TResponse}"/> call.
/// </summary>
private sealed class StubSender : ISender
{
private readonly object? _response;
public StubSender(object? response) => _response = response;
public Task<TResponse> Send<TResponse>(IRequest<TResponse> request, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult((TResponse)_response!);
}
public Task Send<TRequest>(TRequest request, CancellationToken cancellationToken = default) where TRequest : IRequest
{
cancellationToken.ThrowIfCancellationRequested();
return Task.CompletedTask;
}
public Task<object?> Send(object request, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(_response);
}
public IAsyncEnumerable<TResponse> CreateStream<TResponse>(IStreamRequest<TResponse> request, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public IAsyncEnumerable<object?> CreateStream(object request, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
}
private static ISender CreateSender<T>(T? response) => new StubSender(response);
#endregion
#region Throw non-null scalar
[Test]
public async Task Throw_WithNonNullResponse_ReturnsResponse()
{
var sender = CreateSender<string>("hello");
var request = new StubRequest<string?>();
var result = await sender.GetOr(request).Throw(() => new InvalidOperationException());
Assert.That(result, Is.EqualTo("hello"));
}
#endregion
#region Throw null response
[Test]
public void Throw_WithNullResponse_ThrowsCustomException()
{
var sender = CreateSender<string>(null);
var request = new StubRequest<string?>();
var ex = Assert.ThrowsAsync<InvalidOperationException>(
() => sender.GetOr(request).Throw(() => new InvalidOperationException("custom")));
Assert.That(ex!.Message, Is.EqualTo("custom"));
}
#endregion
#region Throw empty collection
[Test]
public void Throw_WithEmptyList_ThrowsCustomException()
{
var sender = CreateSender<List<string>>(new List<string>());
var request = new StubRequest<List<string>?>();
Assert.ThrowsAsync<ArgumentException>(
() => sender.GetOr(request).Throw(() => new ArgumentException("empty")));
}
#endregion
#region Throw non-empty collection
[Test]
public async Task Throw_WithNonEmptyList_ReturnsResponse()
{
var expected = new List<int> { 1, 2 };
var sender = CreateSender<List<int>>(expected);
var request = new StubRequest<List<int>?>();
var result = await sender.GetOr(request).Throw(() => new InvalidOperationException());
Assert.That(result, Is.EqualTo(expected));
}
#endregion
#region Throw string edge case (string implements IEnumerable)
[Test]
public async Task Throw_WithEmptyString_ReturnsEmptyString()
{
var sender = CreateSender<string>("");
var request = new StubRequest<string?>();
var result = await sender.GetOr(request).Throw(() => new InvalidOperationException("should not throw"));
Assert.That(result, Is.EqualTo(""));
}
#endregion
#region ThrowNotFound non-null scalar
[Test]
public async Task ThrowNotFound_WithNonNullResponse_ReturnsResponse()
{
var sender = CreateSender<string>("hello");
var request = new StubRequest<string?>();
var result = await sender.GetOr(request).ThrowNotFound();
Assert.That(result, Is.EqualTo("hello"));
}
[Test]
public async Task ThrowNotFound_WithExceptionMessage_AndNonNullResponse_ReturnsResponse()
{
var sender = CreateSender<int>(42);
var request = new StubRequest<int?>();
var result = await sender.GetOr(request).ThrowNotFound("not found");
Assert.That(result, Is.EqualTo(42));
}
#endregion
#region ThrowNotFound null response
[Test]
public void ThrowNotFound_WithNullResponse_ThrowsNotFoundException()
{
var sender = CreateSender<string>(null);
var request = new StubRequest<string?>();
Assert.ThrowsAsync<NotFoundException>(() => sender.GetOr(request).ThrowNotFound());
}
[Test]
public void ThrowNotFound_WithNullResponse_AndCustomMessage_ContainsMessage()
{
const string message = "Entity not found";
var sender = CreateSender<string>(null);
var request = new StubRequest<string?>();
var ex = Assert.ThrowsAsync<NotFoundException>(
() => sender.GetOr(request).ThrowNotFound(message));
Assert.That(ex!.Message, Does.Contain(message));
}
[Test]
public void ThrowNotFound_WithNullResponse_HasDefaultMessageWithTypeName()
{
var sender = CreateSender<string>(null);
var request = new StubRequest<string?>();
var ex = Assert.ThrowsAsync<NotFoundException>(() => sender.GetOr(request).ThrowNotFound());
Assert.That(ex!.Message, Does.Contain(nameof(String)));
}
#endregion
#region ThrowNotFound empty collection
[Test]
public void ThrowNotFound_WithEmptyList_ThrowsNotFoundException()
{
var sender = CreateSender<List<string>>(new List<string>());
var request = new StubRequest<List<string>?>();
Assert.ThrowsAsync<NotFoundException>(() => sender.GetOr(request).ThrowNotFound());
}
[Test]
public void ThrowNotFound_WithEmptyArray_ThrowsNotFoundException()
{
var sender = CreateSender<int[]>(Array.Empty<int>());
var request = new StubRequest<int[]?>();
Assert.ThrowsAsync<NotFoundException>(() => sender.GetOr(request).ThrowNotFound());
}
[Test]
public void ThrowNotFound_WithEmptyCollection_AndCustomMessage_ContainsMessage()
{
const string message = "No items found";
var sender = CreateSender<List<int>>(new List<int>());
var request = new StubRequest<List<int>?>();
var ex = Assert.ThrowsAsync<NotFoundException>(
() => sender.GetOr(request).ThrowNotFound(message));
Assert.That(ex!.Message, Does.Contain(message));
}
#endregion
#region ThrowNotFound non-empty collection
[Test]
public async Task ThrowNotFound_WithNonEmptyList_ReturnsResponse()
{
var expected = new List<string> { "a", "b" };
var sender = CreateSender<List<string>>(expected);
var request = new StubRequest<List<string>?>();
var result = await sender.GetOr(request).ThrowNotFound();
Assert.That(result, Is.EqualTo(expected));
}
[Test]
public async Task ThrowNotFound_WithNonEmptyArray_ReturnsResponse()
{
var expected = new[] { 1, 2, 3 };
var sender = CreateSender<int[]>(expected);
var request = new StubRequest<int[]?>();
var result = await sender.GetOr(request).ThrowNotFound();
Assert.That(result, Is.EqualTo(expected));
}
#endregion
#region ThrowInvalidOperation null response
[Test]
public void ThrowInvalidOperation_WithNullResponse_ThrowsInvalidOperationException()
{
var sender = CreateSender<string>(null);
var request = new StubRequest<string?>();
Assert.ThrowsAsync<InvalidOperationException>(
() => sender.GetOr(request).ThrowInvalidOperation());
}
[Test]
public void ThrowInvalidOperation_WithNullResponse_AndCustomMessage_ContainsMessage()
{
const string message = "Something went wrong";
var sender = CreateSender<string>(null);
var request = new StubRequest<string?>();
var ex = Assert.ThrowsAsync<InvalidOperationException>(
() => sender.GetOr(request).ThrowInvalidOperation(message));
Assert.That(ex!.Message, Does.Contain(message));
}
#endregion
#region CancellationToken
[Test]
public void Throw_WithCancelledToken_ThrowsOperationCanceledException()
{
var sender = CreateSender<string>("value");
var request = new StubRequest<string?>();
var cts = new CancellationTokenSource();
cts.Cancel();
Assert.ThrowsAsync<OperationCanceledException>(
() => sender.GetOr(request, cts.Token).Throw(() => new InvalidOperationException()));
}
[Test]
public void ThrowNotFound_WithCancelledToken_ThrowsOperationCanceledException()
{
var sender = CreateSender<string>("value");
var request = new StubRequest<string?>();
var cts = new CancellationTokenSource();
cts.Cancel();
Assert.ThrowsAsync<OperationCanceledException>(
() => sender.GetOr(request, cts.Token).ThrowNotFound());
}
#endregion
}