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 PushToUserAsync(string externalId, string text, CancellationToken ct = default) { UserPushes.Add((externalId, text)); return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null)); } public Task PushToGroupAsync(string externalId, string text, CancellationToken ct = default) { GroupPushes.Add((externalId, text)); return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null)); } public Task 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(); 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; } [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(), "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(), "admin-1"); Assert.Equal(0, result.SentCount); Assert.Empty(channel.UserPushes); } }