Update mock email system

This commit is contained in:
Aaron Po
2026-02-15 22:34:08 -05:00
parent e13691df36
commit 584c47e162
9 changed files with 155 additions and 51 deletions

View File

@@ -0,0 +1,54 @@
using Infrastructure.Email;
namespace API.Specs.Mocks;
/// <summary>
/// Mock email provider for testing that doesn't actually send emails.
/// Tracks sent emails for verification in tests if needed.
/// </summary>
public class MockEmailProvider : IEmailProvider
{
public List<SentEmail> SentEmails { get; } = new();
public Task SendAsync(string to, string subject, string body, bool isHtml = true)
{
SentEmails.Add(new SentEmail
{
To = [to],
Subject = subject,
Body = body,
IsHtml = isHtml,
SentAt = DateTime.UtcNow
});
return Task.CompletedTask;
}
public Task SendAsync(IEnumerable<string> to, string subject, string body, bool isHtml = true)
{
SentEmails.Add(new SentEmail
{
To = to.ToList(),
Subject = subject,
Body = body,
IsHtml = isHtml,
SentAt = DateTime.UtcNow
});
return Task.CompletedTask;
}
public void Clear()
{
SentEmails.Clear();
}
public class SentEmail
{
public List<string> To { get; init; } = new();
public string Subject { get; init; } = string.Empty;
public string Body { get; init; } = string.Empty;
public bool IsHtml { get; init; }
public DateTime SentAt { get; init; }
}
}

View File

@@ -1,37 +1,18 @@
using Infrastructure.Email; using Domain.Entities;
using Service.Emails;
namespace API.Specs.Mocks; namespace API.Specs.Mocks;
/// <summary> public class MockEmailService : IEmailService
/// Mock email service for testing that doesn't actually send emails.
/// Tracks sent emails for verification in tests if needed.
/// </summary>
public class MockEmailProvider : IEmailProvider
{ {
public List<SentEmail> SentEmails { get; } = new(); public List<RegistrationEmail> SentRegistrationEmails { get; } = new();
public Task SendAsync(string to, string subject, string body, bool isHtml = true) public Task SendRegistrationEmailAsync(UserAccount createdUser, string confirmationToken)
{ {
SentEmails.Add(new SentEmail SentRegistrationEmails.Add(new RegistrationEmail
{ {
To = [to], UserAccount = createdUser,
Subject = subject, ConfirmationToken = confirmationToken,
Body = body,
IsHtml = isHtml,
SentAt = DateTime.UtcNow
});
return Task.CompletedTask;
}
public Task SendAsync(IEnumerable<string> to, string subject, string body, bool isHtml = true)
{
SentEmails.Add(new SentEmail
{
To = to.ToList(),
Subject = subject,
Body = body,
IsHtml = isHtml,
SentAt = DateTime.UtcNow SentAt = DateTime.UtcNow
}); });
@@ -40,15 +21,13 @@ public class MockEmailProvider : IEmailProvider
public void Clear() public void Clear()
{ {
SentEmails.Clear(); SentRegistrationEmails.Clear();
} }
public class SentEmail public class RegistrationEmail
{ {
public List<string> To { get; init; } = new(); public UserAccount UserAccount { get; init; } = null!;
public string Subject { get; init; } = string.Empty; public string ConfirmationToken { get; init; } = string.Empty;
public string Body { get; init; } = string.Empty;
public bool IsHtml { get; init; }
public DateTime SentAt { get; init; } public DateTime SentAt { get; init; }
} }
} }

View File

@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Service.Emails;
namespace API.Specs namespace API.Specs
{ {
@@ -16,16 +17,27 @@ namespace API.Specs
builder.ConfigureServices(services => builder.ConfigureServices(services =>
{ {
// Replace the real email service with mock for testing // Replace the real email provider with mock for testing
var descriptor = services.SingleOrDefault( var emailProviderDescriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(IEmailProvider)); d => d.ServiceType == typeof(IEmailProvider));
if (descriptor != null) if (emailProviderDescriptor != null)
{ {
services.Remove(descriptor); services.Remove(emailProviderDescriptor);
} }
services.AddScoped<IEmailProvider, MockEmailProvider>(); services.AddScoped<IEmailProvider, MockEmailProvider>();
// Replace the real email service with mock for testing
var emailServiceDescriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(IEmailService));
if (emailServiceDescriptor != null)
{
services.Remove(emailServiceDescriptor);
}
services.AddScoped<IEmailService, MockEmailService>();
}); });
} }
} }

View File

@@ -21,6 +21,7 @@
</Folder> </Folder>
<Folder Name="/Service/"> <Folder Name="/Service/">
<Project Path="Service/Service.Auth.Tests/Service.Auth.Tests.csproj" /> <Project Path="Service/Service.Auth.Tests/Service.Auth.Tests.csproj" />
<Project Path="Service/Service.Emails/Service.Emails.csproj" />
<Project Path="Service/Service.UserManagement/Service.UserManagement.csproj" /> <Project Path="Service/Service.UserManagement/Service.UserManagement.csproj" />
<Project Path="Service\Service.Auth\Service.Auth.csproj" /> <Project Path="Service\Service.Auth\Service.Auth.csproj" />
</Folder> </Folder>

View File

@@ -4,6 +4,7 @@ using FluentAssertions;
using Infrastructure.PasswordHashing; using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth; using Infrastructure.Repository.Auth;
using Moq; using Moq;
using Service.Emails;
namespace Service.Auth.Tests; namespace Service.Auth.Tests;
@@ -12,6 +13,7 @@ public class RegisterServiceTest
private readonly Mock<IAuthRepository> _authRepoMock; private readonly Mock<IAuthRepository> _authRepoMock;
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock; private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
private readonly Mock<ITokenService> _tokenServiceMock; private readonly Mock<ITokenService> _tokenServiceMock;
private readonly Mock<IEmailService> _emailServiceMock; // todo handle email related test cases here
private readonly RegisterService _registerService; private readonly RegisterService _registerService;
public RegisterServiceTest() public RegisterServiceTest()
@@ -19,11 +21,14 @@ public class RegisterServiceTest
_authRepoMock = new Mock<IAuthRepository>(); _authRepoMock = new Mock<IAuthRepository>();
_passwordInfraMock = new Mock<IPasswordInfrastructure>(); _passwordInfraMock = new Mock<IPasswordInfrastructure>();
_tokenServiceMock = new Mock<ITokenService>(); _tokenServiceMock = new Mock<ITokenService>();
_emailServiceMock = new Mock<IEmailService>();
_registerService = new RegisterService( _registerService = new RegisterService(
_authRepoMock.Object, _authRepoMock.Object,
_passwordInfraMock.Object, _passwordInfraMock.Object,
_tokenServiceMock.Object _tokenServiceMock.Object,
_emailServiceMock.Object
); );
} }
@@ -92,6 +97,7 @@ public class RegisterServiceTest
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>())) .Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
.Returns("refresh-token"); .Returns("refresh-token");
// Act // Act
var result = await _registerService.RegisterAsync( var result = await _registerService.RegisterAsync(
userAccount, userAccount,
@@ -128,6 +134,8 @@ public class RegisterServiceTest
), ),
Times.Once Times.Once
); );
_emailServiceMock.Verify(x => x.SendRegistrationEmailAsync(It.IsAny<UserAccount>(), It.IsAny<string>()),
Times.Once);
} }
[Fact] [Fact]

View File

@@ -4,13 +4,15 @@ using Infrastructure.Email;
using Infrastructure.Email.Templates.Rendering; using Infrastructure.Email.Templates.Rendering;
using Infrastructure.PasswordHashing; using Infrastructure.PasswordHashing;
using Infrastructure.Repository.Auth; using Infrastructure.Repository.Auth;
using Service.Emails;
namespace Service.Auth; namespace Service.Auth;
public class RegisterService( public class RegisterService(
IAuthRepository authRepo, IAuthRepository authRepo,
IPasswordInfrastructure passwordInfrastructure, IPasswordInfrastructure passwordInfrastructure,
ITokenService tokenService ITokenService tokenService,
IEmailService emailService
) : IRegisterService ) : IRegisterService
{ {
private async Task ValidateUserDoesNotExist(UserAccount userAccount) private async Task ValidateUserDoesNotExist(UserAccount userAccount)
@@ -51,6 +53,9 @@ public class RegisterService(
var accessToken = tokenService.GenerateAccessToken(createdUser); var accessToken = tokenService.GenerateAccessToken(createdUser);
var refreshToken = tokenService.GenerateRefreshToken(createdUser); var refreshToken = tokenService.GenerateRefreshToken(createdUser);
// send confirmation email
await emailService.SendRegistrationEmailAsync(createdUser, "some-confirmation-token");
return new AuthServiceReturn(createdUser, refreshToken, accessToken); return new AuthServiceReturn(createdUser, refreshToken, accessToken);
} }
} }

View File

@@ -5,20 +5,18 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference
Include="Konscious.Security.Cryptography.Argon2"
Version="1.3.1"
/>
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" /> <ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" /> <ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" /> <ProjectReference
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" /> Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" /> <ProjectReference
<ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" /> Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
<ProjectReference
Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
<ProjectReference Include="..\Service.Emails\Service.Emails.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,32 @@
using Domain.Entities;
using Infrastructure.Email;
using Infrastructure.Email.Templates.Rendering;
namespace Service.Emails;
public interface IEmailService
{
public Task SendRegistrationEmailAsync(UserAccount createdUser, string confirmationToken);
}
public class EmailService(
IEmailProvider emailProvider,
IEmailTemplateProvider emailTemplateProvider) : IEmailService
{
public async Task SendRegistrationEmailAsync(UserAccount createdUser, string confirmationToken)
{
var confirmationLink = $"https://thebiergarten.app/confirm?token={confirmationToken}";
var emailHtml = await emailTemplateProvider.RenderUserRegisteredEmailAsync(
createdUser.FirstName,
confirmationLink
);
await emailProvider.SendAsync(
createdUser.Email,
"Welcome to The Biergarten App!",
emailHtml,
isHtml: true
);
}
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
</ItemGroup>
</Project>