@@ -12,6 +12,7 @@ public class LineMessageChannelTests
|
||||
{
|
||||
public SmtpOptions GetSmtp() => new();
|
||||
public LineOptions GetLine() => new() { ChannelAccessToken = "tok", ChannelSecret = "sec" };
|
||||
public WebPushOptions GetWebPush() => new();
|
||||
public void Reload() { }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
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<string> SentToEndpoints { get; } = new();
|
||||
public string? ExpireForEndpoint { get; set; }
|
||||
public string? FailForEndpoint { get; set; }
|
||||
|
||||
public Task<WebPushSendResult> 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<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)
|
||||
{
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user