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 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(); 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() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .AddInterceptors(interceptor) .Options); } private static async Task 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: "

Body

"); 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(), "Hi", "

Body

")); 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(), new[] { "bad@example.com", "good@example.com" }, "Hi", "

Body

")); 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)); } }