I am building website with a donation system for a charity as a pro bono project. After a user gives a memorial donation, a tribute PDF is to be generated and sent to the recipient.
I have a unit test for PdfService.GenerateMemorialPdf(...) which works fine. However, when testing my event handler via my task queue, which in turn calls the aforementioned method, the debugging session breaks abruptly when the PDF is to be generated. I cannot find any exceptions.
If I modify the test to call ExecuteAsync directly (rather than the event handler's Handle()) then the test completes. So I think the issue is connected to my test strategy with respect to the task queue.
I’m a junior developer with a couple of months of experience.
Any help would be much appreciated!
The code
The event handler is an implementation of my EnqueueingEventHandler, which enqueues ExecuteAsync(). The taskQueue is essentially the same as described in Microsoft Learn.
namespace CharityName.Application.Events;
public abstract class EnqueueingEventHandler<TEvent>(IBackgroundTaskQueue taskQueue) : IEventHandler<TEvent>
where TEvent : IEvent
{
public Task Handle(TEvent eventRequest, CancellationToken cancellationToken)
{
taskQueue.QueueBackgroundWorkItemAsync((CancellationToken) => ExecuteAsync(eventRequest, CancellationToken));
return Task.CompletedTask;
}
protected abstract ValueTask ExecuteAsync(TEvent eventRequest, CancellationToken cancellationToken);
}
Here is the implementation of the donation created event handler, which currently only generates a PDF and sends it to the recipient via email. This is the method being tested (test follows in the end).
namespace CharityName.Application.Events.DonationCreatedEvent;
public class DonationCreatedEventHandler(
ILogger<DonationCreatedEventHandler> logger,
IPdfService pdfService,
IEmailService emailService,
IBackgroundTaskQueue taskQueue)
: EnqueueingEventHandler<DonationCreatedEvent>(taskQueue)
{
protected override async ValueTask ExecuteAsync(DonationCreatedEvent eventRequest, CancellationToken cancellationToken)
{
logger.LogInformation("Event {event} is being dequeued from the taskqueue", typeof(DonationCreatedEvent));
string? pdf = null;
if (eventRequest.DonationDto.DonationType == Domain.Enums.DonationType.Memorial)
{
pdf = pdfService.GenerateMemorialPdf(eventRequest.LetterDto.FullName, eventRequest.LetterDto.Message, PdfImage.heart_filled);
}
var res = await emailService.SendRecieptAsync(
EmailTemplate.reciept,
eventRequest.DonorDto.Email,
eventRequest.DonorDto.FirstName,
pdf,
"Temporary");
return;
}
}
Here is the PdfService. Note the comment indicating where the debugging session crashes. Remember, this method works fine in unit test.
namespace CharityName.Infrastructure.Services.PdfService;
public class PdfService: IPdfService
{
private const string BaseImagePath = "Services/PdfService/Resources/";
public string GenerateMemorialPdf(string honoreeName, string message, PdfImage image)
{
// Temporary code, this does not throw
var imagePath = BaseImagePath + image + ".png";
if (!File.Exists(imagePath))
{
throw new FileNotFoundException($"Image file not found at path: {imagePath}");
}
var documentModel = new MemorialDocumentModel()
{
HonoreeName = honoreeName,
Message = message,
Image = new Image() { Location = BaseImagePath + image + ".png"}
};
var document = new MemorialDocument(documentModel);
var pdf = document.GeneratePdf(); // <--- debug session ends here
return Convert.ToBase64String(pdf);
}
}
Here's the test
namespace CharityName.Tests.IntegrationTests
{
public class DonationCreatedEventHandlerTests
{
private readonly EmailService _emailService;
private readonly PdfService _pdfService;
private readonly BackgroundTaskQueue _taskQeueue;
private readonly QueuedHostedService _queuedHostedService;
private readonly IConfiguration _configuration;
public DonationCreatedEventHandlerTests()
{
_configuration = new ConfigurationBuilder()
.AddUserSecrets<DonationCreatedEventHandlerTests>()
.Build();
var brevoHttpClient = BrevoClientTestHelper.Create(_configuration);
_emailService = new EmailService(brevoHttpClient, new Mock<ILogger<EmailService>>().Object);
_pdfService = new PdfService();
_taskQeueue = new BackgroundTaskQueue(100);
_queuedHostedService = new QueuedHostedService(_taskQeueue, new Mock<ILogger<QueuedHostedService>>().Object);
QuestPDF.Settings.License = LicenseType.Community;
}
[Fact]
public async Task Test1()
{
// Arrange
await _queuedHostedService.StartAsync(CancellationToken.None);
var handler = new DonationCreatedEventHandler(
new Mock<ILogger<DonationCreatedEventHandler>>().Object,
_pdfService,
_emailService,
_taskQeueue);
var donationDto = new DonationDto()
{
Amount = 5000,
DonationType = Domain.Enums.DonationType.Memorial,
Token = Guid.NewGuid()
};
var donorDto = new DonorDto()
{
FirstName = "John",
LastName = "Doe",
Email = "[email protected]"
};
var letterDto = new LetterDto()
{
LetterType = Domain.Enums.LetterType.Celebratory,
DeliveryMethod = Domain.Enums.DeliveryMethod.email,
LatestsDeliveryDate = DateTime.Now,
FullName = "John Doe",
Email = "[email protected]",
Message = "Rest in Peace"
};
var @event = new DonationCreatedEvent(donationDto, donorDto, letterDto);
// Act
await handler.Handle(@event, CancellationToken.None);
// Assert
Assert.True(true); // to be changed
}
}
}
Update:
Might as well add the taskQueue and hosted service.
using System.Threading.Channels;
using CharityName.Application.Abstractions.Interfaces;
public class BackgroundTaskQueue : IBackgroundTaskQueue
{
private readonly Channel<Func<CancellationToken, ValueTask>> _queue;
public BackgroundTaskQueue(int capacity)
{
// Capacity should be set based on the expected application load and
// number of concurrent threads accessing the queue.
// BoundedChannelFullMode.Wait will cause calls to WriteAsync() to return a task,
// which completes only when space became available. This leads to backpressure,
// in case too many publishers/calls start accumulating.
var options = new BoundedChannelOptions(capacity)
{
FullMode = BoundedChannelFullMode.Wait
};
_queue = Channel.CreateBounded<Func<CancellationToken, ValueTask>>(options);
}
public async ValueTask QueueBackgroundWorkItemAsync(
Func<CancellationToken, ValueTask> workItem)
{
if (workItem == null)
{
throw new ArgumentNullException(nameof(workItem));
}
await _queue.Writer.WriteAsync(workItem);
}
public async ValueTask<Func<CancellationToken, ValueTask>> DequeueAsync(
CancellationToken cancellationToken)
{
var workItem = await _queue.Reader.ReadAsync(cancellationToken);
return workItem;
}
}
using CharityName.Application.Abstractions.Interfaces;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
public class QueuedHostedService(IBackgroundTaskQueue taskQueue,
ILogger<QueuedHostedService> logger) : BackgroundService
{
public IBackgroundTaskQueue TaskQueue { get; } = taskQueue;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Queued Hosted Service is running.");
await BackgroundProcessing(stoppingToken);
}
private async Task BackgroundProcessing(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var workItem =
await TaskQueue.DequeueAsync(stoppingToken);
try
{
await workItem(stoppingToken);
}
catch (Exception ex)
{
logger.LogError(ex,
"Error occurred executing {WorkItem}.", nameof(workItem));
}
}
}
public override async Task StopAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Queued Hosted Service is stopping.");
await base.StopAsync(stoppingToken);
}
}
MemorialDocument
(not part of QuestPDF AFAICS). This might be something in the internals of QuestPDF (which appears to be a wrapper around an underlying QuestPdfSkia which is included as a dll) which likely means you may need to enable native debugging and disable "just my code" options.MemorialDocument
: focus inside that. But as the warning about extended discussion notes; this is very specifically "how do I debug a problem" and that is something that needs interactive help (which is beyond the effort I do for free).