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.Entities.Notifications; using ROLAC.API.Services.Logging; using ROLAC.API.Services.Notifications; using Xunit; namespace ROLAC.API.Tests.Services.Notifications; public class WebPushServiceTests { // Records the endpoints it was asked to push to; can be told to fail or to report "gone" for one. private sealed class FakeWebPushSender : IWebPushSender { public List SentToEndpoints { get; } = new(); public string? ExpireForEndpoint { get; set; } public string? FailForEndpoint { get; set; } public Task SendAsync(WebPushTarget target, string payloadJson, CancellationToken ct = default) { if (target.Endpoint == ExpireForEndpoint) return Task.FromResult(new WebPushSendResult(Success: false, IsExpired: true, Error: "gone")); if (target.Endpoint == FailForEndpoint) return Task.FromResult(new WebPushSendResult(Success: false, IsExpired: false, Error: "transient")); SentToEndpoints.Add(target.Endpoint); return Task.FromResult(new WebPushSendResult(Success: true, IsExpired: false, Error: null)); } } 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) { var member = new Member { FirstName_en = "Test", LastName_en = "User" }; db.Members.Add(member); await db.SaveChangesAsync(); return member.Id; } private static async Task SeedSubscriptionAsync(AppDbContext db, int memberId, string endpoint) { db.WebPushSubscriptions.Add(new WebPushSubscription { MemberId = memberId, Endpoint = endpoint, P256dh = "key", Auth = "auth", CreatedAt = DateTime.UtcNow, }); await db.SaveChangesAsync(); } private static WebPushPayload Payload() => new("Title", "Body", "/user-portal/finance/expenses"); [Fact] public async Task SendToMembersAsync_PushesToEveryDeviceSubscription_AndLogsEach() { using var db = BuildDb(); var memberId = await SeedMemberAsync(db); await SeedSubscriptionAsync(db, memberId, "https://push.example/phone"); await SeedSubscriptionAsync(db, memberId, "https://push.example/laptop"); var sender = new FakeWebPushSender(); var service = new WebPushService(db, sender, BuildAccessor()); var result = await service.SendToMembersAsync(new[] { memberId }, Payload(), "test-user"); Assert.Equal(2, result.SentCount); Assert.Equal(0, result.FailedCount); Assert.Equal(2, sender.SentToEndpoints.Count); Assert.Equal(2, await db.NotificationLogs.CountAsync(l => l.Channel == NotificationChannels.WebPush && l.Status == NotificationStatuses.Sent)); } [Fact] public async Task SendToMembersAsync_PrunesSubscription_WhenPushServiceReportsGone() { using var db = BuildDb(); var memberId = await SeedMemberAsync(db); await SeedSubscriptionAsync(db, memberId, "https://push.example/dead"); await SeedSubscriptionAsync(db, memberId, "https://push.example/live"); var sender = new FakeWebPushSender { ExpireForEndpoint = "https://push.example/dead" }; var service = new WebPushService(db, sender, BuildAccessor()); var result = await service.SendToMembersAsync(new[] { memberId }, Payload(), "test-user"); Assert.Equal(1, result.SentCount); Assert.Equal(1, result.FailedCount); var remaining = await db.WebPushSubscriptions.Select(s => s.Endpoint).ToListAsync(); Assert.Equal(new[] { "https://push.example/live" }, remaining); } [Fact] public async Task SendToMembersAsync_LogsTransientFailure_WithoutPruning_OrAborting() { using var db = BuildDb(); var memberId = await SeedMemberAsync(db); await SeedSubscriptionAsync(db, memberId, "https://push.example/flaky"); await SeedSubscriptionAsync(db, memberId, "https://push.example/ok"); var sender = new FakeWebPushSender { FailForEndpoint = "https://push.example/flaky" }; var service = new WebPushService(db, sender, BuildAccessor()); var result = await service.SendToMembersAsync(new[] { memberId }, Payload(), "test-user"); Assert.Equal(1, result.SentCount); Assert.Equal(1, result.FailedCount); Assert.Single(result.Failures); Assert.Equal(2, await db.WebPushSubscriptions.CountAsync()); // transient failure does NOT prune Assert.Equal(1, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Failed)); } [Fact] public async Task SendToMembersAsync_ReturnsEmpty_WhenMemberHasNoSubscription() { using var db = BuildDb(); var memberId = await SeedMemberAsync(db); var sender = new FakeWebPushSender(); var service = new WebPushService(db, sender, BuildAccessor()); var result = await service.SendToMembersAsync(new[] { memberId }, Payload(), "test-user"); Assert.Equal(0, result.SentCount); Assert.Empty(sender.SentToEndpoints); Assert.Equal(0, await db.NotificationLogs.CountAsync()); } }