0ddb34dd20
TDD: IEmailService interface, EmailService resolves member emails + raw addresses (case-insensitive dedup), sends via ISmtpDispatcher, writes a NotificationLog per recipient (sent/failed), and never aborts the batch on a single failure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
113 lines
4.1 KiB
C#
113 lines
4.1 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Moq;
|
|
using ROLAC.API.Data;
|
|
using ROLAC.API.Data.Interceptors;
|
|
using ROLAC.API.Entities;
|
|
using ROLAC.API.Services.Logging;
|
|
using ROLAC.API.Services.Notifications;
|
|
using Xunit;
|
|
|
|
namespace ROLAC.API.Tests.Services.Notifications;
|
|
|
|
public class EmailServiceTests
|
|
{
|
|
// Records every email it is asked to send; can be told to throw for a given address.
|
|
private sealed class FakeSmtpDispatcher : ISmtpDispatcher
|
|
{
|
|
public List<OutboundEmail> Sent { get; } = new();
|
|
public string? FailForAddress { get; set; }
|
|
|
|
public Task SendAsync(OutboundEmail email, CancellationToken ct = default)
|
|
{
|
|
if (email.ToAddress == FailForAddress)
|
|
throw new InvalidOperationException("smtp rejected");
|
|
Sent.Add(email);
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private static CurrentUserAccessor BuildAccessor(string userId = "test-user")
|
|
{
|
|
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
|
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
|
|
var mock = new Mock<IHttpContextAccessor>();
|
|
mock.Setup(x => x.HttpContext).Returns(ctx);
|
|
return new CurrentUserAccessor(mock.Object);
|
|
}
|
|
|
|
private static AppDbContext BuildDb()
|
|
{
|
|
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor());
|
|
return new AppDbContext(
|
|
new DbContextOptionsBuilder<AppDbContext>()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.AddInterceptors(interceptor)
|
|
.Options);
|
|
}
|
|
|
|
private static async Task<int> SeedMemberAsync(AppDbContext db, string? email)
|
|
{
|
|
var member = new Member { FirstName_en = "Test", LastName_en = "User", Email = email };
|
|
db.Members.Add(member);
|
|
await db.SaveChangesAsync();
|
|
return member.Id;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendAsync_ResolvesMemberEmails_MergesRawAddresses_AndDedupes()
|
|
{
|
|
using var db = BuildDb();
|
|
var memberId = await SeedMemberAsync(db, "member@example.com");
|
|
var dispatcher = new FakeSmtpDispatcher();
|
|
var service = new EmailService(db, dispatcher, BuildAccessor());
|
|
|
|
var message = new EmailMessage(
|
|
MemberIds: new[] { memberId },
|
|
Addresses: new[] { "extra@example.com", "member@example.com" }, // dup of member email
|
|
Subject: "Hi", HtmlBody: "<p>Body</p>");
|
|
|
|
var result = await service.SendAsync(message);
|
|
|
|
Assert.Equal(2, result.SentCount); // member@ + extra@, dup dropped
|
|
Assert.Equal(0, result.FailedCount);
|
|
Assert.Equal(2, dispatcher.Sent.Count);
|
|
Assert.Equal(2, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Sent));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendAsync_SkipsMembersWithNoEmail()
|
|
{
|
|
using var db = BuildDb();
|
|
var memberId = await SeedMemberAsync(db, null);
|
|
var dispatcher = new FakeSmtpDispatcher();
|
|
var service = new EmailService(db, dispatcher, BuildAccessor());
|
|
|
|
var result = await service.SendAsync(new EmailMessage(
|
|
new[] { memberId }, Array.Empty<string>(), "Hi", "<p>Body</p>"));
|
|
|
|
Assert.Equal(0, result.SentCount);
|
|
Assert.Empty(dispatcher.Sent);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendAsync_LogsFailure_WithoutAbortingBatch()
|
|
{
|
|
using var db = BuildDb();
|
|
var dispatcher = new FakeSmtpDispatcher { FailForAddress = "bad@example.com" };
|
|
var service = new EmailService(db, dispatcher, BuildAccessor());
|
|
|
|
var result = await service.SendAsync(new EmailMessage(
|
|
Array.Empty<int>(),
|
|
new[] { "bad@example.com", "good@example.com" },
|
|
"Hi", "<p>Body</p>"));
|
|
|
|
Assert.Equal(1, result.SentCount);
|
|
Assert.Equal(1, result.FailedCount);
|
|
Assert.Single(result.Failures);
|
|
Assert.Equal("bad@example.com", result.Failures[0].Target);
|
|
Assert.Equal(1, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Failed));
|
|
}
|
|
}
|