feat: consume transactional email notifications

Add contract validation, SMTP delivery results, terminal failure context, neutral development seeding, and local Docker setup.

Ref: IT-1033
This commit is contained in:
2026-08-04 12:32:28 +03:00
parent b8435ac07b
commit 2757869176
37 changed files with 1465 additions and 122 deletions
@@ -0,0 +1,123 @@
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
using Hrynco.RabbitMq;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
public sealed class SendEmailServiceTests
{
[Fact]
public async Task ProcessAsync_ValidItemTrackerMessage_SendsOnceAndUpdatesUsage()
{
Guid channelId = Guid.NewGuid();
var settings = new SmtpChannelSettings
{
Host = "smtp.example.invalid",
Port = 587,
Username = "smtp-user",
Password = "not-a-real-secret",
UseSsl = true,
FromEmail = "notifications@example.com",
FromName = "StoreMate"
};
var channel = new EmailChannel
{
Id = channelId,
ServiceName = "StoreMate-Prod",
Priority = 1,
EmailChannelType = EmailChannelType.Smtp,
Settings = settings,
IsActive = true
};
var template = new EmailTemplate
{
ServiceName = "StoreMate-Prod",
Key = "EmailVerification",
LanguageCode = "uk",
Subject = "Verify {{AppName}}",
HtmlBody = "<p>{{AppName}}</p>",
TextBody = "{{AppName}}",
Variables = [new EmailTemplateVariable { Name = "AppName", Required = true }]
};
IEmailChannelRepository channels = Substitute.For<IEmailChannelRepository>();
channels.GetByServiceAsync("StoreMate-Prod", Arg.Any<CancellationToken>())
.Returns([channel]);
IEmailChannelUsageRepository usage = Substitute.For<IEmailChannelUsageRepository>();
IEmailTemplateService templates = Substitute.For<IEmailTemplateService>();
templates.GetAsync("StoreMate-Prod", "EmailVerification", "uk", Arg.Any<CancellationToken>())
.Returns(template);
var renderer = new EmailTemplateRenderingService();
var smtp = new RecordingSmtpEmailSender();
INotificationResultPublisher resultPublisher = Substitute.For<INotificationResultPublisher>();
var service = new SendEmailService(
channels,
usage,
templates,
renderer,
smtp,
resultPublisher,
NullLogger<SendEmailService>.Instance);
var message = new SendEmailMessage
{
CorrelationContext = new CorrelationContext
{
CorrelationId = Guid.NewGuid().ToString("D")
},
Data = new SendEmailMessageData
{
ServiceName = "StoreMate-Prod",
TemplateKey = "EmailVerification",
RecipientEmail = "owner@example.com",
RecipientName = "Owner",
LanguageCode = "uk",
Variables = new Dictionary<string, string> { ["AppName"] = "StoreMate" }
}
};
await service.ProcessAsync(message, CancellationToken.None);
Assert.Equal(1, smtp.SendCount);
Assert.Same(settings, smtp.Settings);
Assert.Equal("Verify StoreMate", smtp.Email?.Subject);
Assert.Equal("owner@example.com", smtp.RecipientEmail);
Assert.Equal("Owner", smtp.RecipientName);
await usage.Received(1).IncrementUsageAsync(
channelId,
Arg.Any<DateOnly>(),
CancellationToken.None);
await resultPublisher.Received(1).PublishAsync(
message,
null,
CancellationToken.None);
}
private sealed class RecordingSmtpEmailSender : ISmtpEmailSender
{
public int SendCount { get; private set; }
public SmtpChannelSettings? Settings { get; private set; }
public RenderedEmail? Email { get; private set; }
public string? RecipientEmail { get; private set; }
public string? RecipientName { get; private set; }
public Task SendAsync(
SmtpChannelSettings settings,
RenderedEmail email,
string recipientEmail,
string recipientName,
CancellationToken cancellationToken)
{
SendCount++;
Settings = settings;
Email = email;
RecipientEmail = recipientEmail;
RecipientName = recipientName;
return Task.CompletedTask;
}
}
}