212 lines
8.5 KiB
C#
212 lines
8.5 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.Entities.Notifications;
|
|
using ROLAC.API.Services.Logging;
|
|
using ROLAC.API.Services.Notifications;
|
|
using Xunit;
|
|
|
|
namespace ROLAC.API.Tests.Services.Notifications;
|
|
|
|
public class LineNotificationServiceTests
|
|
{
|
|
// Records pushes; can be told to fail every call.
|
|
private sealed class FakeMessageChannel : IMessageChannel
|
|
{
|
|
public List<(string Target, string Text)> UserPushes { get; } = new();
|
|
public List<(string Target, string Text)> GroupPushes { get; } = new();
|
|
public bool Fail { get; set; }
|
|
|
|
public Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default)
|
|
{
|
|
UserPushes.Add((externalId, text));
|
|
return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null));
|
|
}
|
|
public Task<MessageSendResult> PushToGroupAsync(string externalId, string text, CancellationToken ct = default)
|
|
{
|
|
GroupPushes.Add((externalId, text));
|
|
return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null));
|
|
}
|
|
public Task<MessageSendResult> ReplyAsync(string replyToken, string text, CancellationToken ct = default)
|
|
=> Task.FromResult(new MessageSendResult(true, 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;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GenerateLineBindingCodeAsync_PersistsUnconsumedCode()
|
|
{
|
|
using var db = BuildDb();
|
|
var memberId = await SeedMemberAsync(db);
|
|
var service = new LineNotificationService(db, new FakeMessageChannel());
|
|
|
|
var code = await service.GenerateLineBindingCodeAsync(memberId);
|
|
|
|
var stored = await db.LineBindingCodes.SingleAsync();
|
|
Assert.Equal(code, stored.Code);
|
|
Assert.Null(stored.ConsumedAt);
|
|
Assert.True(stored.ExpiresAt > DateTime.UtcNow);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryBindMemberAsync_BindsMember_AndConsumesCode()
|
|
{
|
|
using var db = BuildDb();
|
|
var memberId = await SeedMemberAsync(db);
|
|
var service = new LineNotificationService(db, new FakeMessageChannel());
|
|
var code = await service.GenerateLineBindingCodeAsync(memberId);
|
|
|
|
var result = await service.TryBindMemberAsync("U999", code);
|
|
|
|
Assert.True(result.Success);
|
|
Assert.Equal(memberId, result.MemberId);
|
|
var binding = await db.MemberChannelBindings.SingleAsync();
|
|
Assert.Equal("U999", binding.ExternalId);
|
|
Assert.NotNull((await db.LineBindingCodes.SingleAsync()).ConsumedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryBindMemberAsync_Fails_ForExpiredOrUsedOrUnknownCode()
|
|
{
|
|
using var db = BuildDb();
|
|
var memberId = await SeedMemberAsync(db);
|
|
db.LineBindingCodes.Add(new LineBindingCode
|
|
{
|
|
Code = "EXPIRE", MemberId = memberId, ExpiresAt = DateTime.UtcNow.AddMinutes(-1),
|
|
});
|
|
await db.SaveChangesAsync();
|
|
var service = new LineNotificationService(db, new FakeMessageChannel());
|
|
|
|
Assert.False((await service.TryBindMemberAsync("U1", "EXPIRE")).Success); // expired
|
|
Assert.False((await service.TryBindMemberAsync("U1", "NOPE")).Success); // unknown
|
|
Assert.Empty(await db.MemberChannelBindings.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryBindMemberAsync_Rebinds_UpdatesExistingBinding()
|
|
{
|
|
using var db = BuildDb();
|
|
var memberId = await SeedMemberAsync(db);
|
|
var service = new LineNotificationService(db, new FakeMessageChannel());
|
|
await service.TryBindMemberAsync("U-OLD", await service.GenerateLineBindingCodeAsync(memberId));
|
|
|
|
await service.TryBindMemberAsync("U-NEW", await service.GenerateLineBindingCodeAsync(memberId));
|
|
|
|
var binding = await db.MemberChannelBindings.SingleAsync();
|
|
Assert.Equal("U-NEW", binding.ExternalId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RegisterGroupAsync_IsIdempotent_AndDeactivateFlips()
|
|
{
|
|
using var db = BuildDb();
|
|
var service = new LineNotificationService(db, new FakeMessageChannel());
|
|
|
|
await service.RegisterGroupAsync("G1");
|
|
await service.RegisterGroupAsync("G1"); // second call must not duplicate
|
|
Assert.Equal(1, await db.MessagingGroups.CountAsync());
|
|
Assert.True((await db.MessagingGroups.SingleAsync()).IsActive);
|
|
|
|
await service.DeactivateGroupAsync("G1");
|
|
Assert.False((await db.MessagingGroups.SingleAsync()).IsActive);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendLineAsync_PushesToBoundMembersAndActiveGroups_AndLogs()
|
|
{
|
|
using var db = BuildDb();
|
|
var memberId = await SeedMemberAsync(db);
|
|
db.MemberChannelBindings.Add(new MemberChannelBinding
|
|
{
|
|
MemberId = memberId, Channel = "line", ExternalId = "U-MEM", BoundAt = DateTime.UtcNow,
|
|
});
|
|
var activeGroup = new MessagingGroup { Channel = "line", ExternalId = "G-ON", IsActive = true, RegisteredAt = DateTime.UtcNow };
|
|
var deadGroup = new MessagingGroup { Channel = "line", ExternalId = "G-OFF", IsActive = false, RegisteredAt = DateTime.UtcNow };
|
|
db.MessagingGroups.AddRange(activeGroup, deadGroup);
|
|
await db.SaveChangesAsync();
|
|
var channel = new FakeMessageChannel();
|
|
var service = new LineNotificationService(db, channel);
|
|
|
|
var result = await service.SendLineAsync("notice", new[] { memberId },
|
|
new[] { activeGroup.Id, deadGroup.Id }, "admin-1");
|
|
|
|
Assert.Equal(2, result.SentCount); // member + active group only
|
|
Assert.Single(channel.UserPushes);
|
|
Assert.Single(channel.GroupPushes); // inactive group skipped
|
|
Assert.Equal(2, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Sent));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendLineAsync_RecordsFailures_WhenChannelFails()
|
|
{
|
|
using var db = BuildDb();
|
|
var memberId = await SeedMemberAsync(db);
|
|
db.MemberChannelBindings.Add(new MemberChannelBinding
|
|
{
|
|
MemberId = memberId, Channel = "line", ExternalId = "U-MEM", BoundAt = DateTime.UtcNow,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
var service = new LineNotificationService(db, new FakeMessageChannel { Fail = true });
|
|
|
|
var result = await service.SendLineAsync("notice", new[] { memberId }, Array.Empty<int>(), "admin-1");
|
|
|
|
Assert.Equal(0, result.SentCount);
|
|
Assert.Equal(1, result.FailedCount);
|
|
Assert.Equal(1, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Failed));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SendLineAsync_SkipsSoftDeletedMembers()
|
|
{
|
|
using var db = BuildDb();
|
|
var memberId = await SeedMemberAsync(db);
|
|
db.MemberChannelBindings.Add(new MemberChannelBinding
|
|
{
|
|
MemberId = memberId, Channel = "line", ExternalId = "U-DEL", BoundAt = DateTime.UtcNow,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
|
|
// Soft-delete the member.
|
|
var member = await db.Members.FirstAsync(m => m.Id == memberId);
|
|
member.IsDeleted = true;
|
|
await db.SaveChangesAsync();
|
|
|
|
var channel = new FakeMessageChannel();
|
|
var service = new LineNotificationService(db, channel);
|
|
|
|
var result = await service.SendLineAsync("notice", new[] { memberId }, Array.Empty<int>(), "admin-1");
|
|
|
|
Assert.Equal(0, result.SentCount);
|
|
Assert.Empty(channel.UserPushes);
|
|
}
|
|
}
|