Add LineNotificationService with send, binding, and group ops
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ROLAC.API.Services.Notifications;
|
||||
|
||||
/// <summary>Outcome of a webhook-driven binding attempt.</summary>
|
||||
public sealed record LineBindingResult(bool Success, string Message, int? MemberId);
|
||||
|
||||
/// <summary>
|
||||
/// Line-specific notification operations: outbound push to bound members/groups, plus the
|
||||
/// webhook-driven binding-code generation/consumption and group registration.
|
||||
/// </summary>
|
||||
public interface ILineNotificationService
|
||||
{
|
||||
Task<NotificationResult> SendLineAsync(string body, int[] memberIds, int[] groupIds,
|
||||
string sentByUserId, CancellationToken ct = default);
|
||||
|
||||
Task<string> GenerateLineBindingCodeAsync(int memberId, CancellationToken ct = default);
|
||||
|
||||
Task<LineBindingResult> TryBindMemberAsync(string externalId, string code, CancellationToken ct = default);
|
||||
Task RegisterGroupAsync(string externalId, CancellationToken ct = default);
|
||||
Task DeactivateGroupAsync(string externalId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.Entities.Notifications;
|
||||
|
||||
namespace ROLAC.API.Services.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Line outbound push + webhook-driven binding/group operations. All sends write a
|
||||
/// NotificationLog row; binding consumes a short-lived, single-use code.
|
||||
/// </summary>
|
||||
public sealed class LineNotificationService : ILineNotificationService
|
||||
{
|
||||
private const string Channel = NotificationChannels.Line;
|
||||
private const string CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no I/O/0/1
|
||||
private const int CodeLength = 6;
|
||||
private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(15);
|
||||
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IMessageChannel _channel;
|
||||
|
||||
public LineNotificationService(AppDbContext db, IMessageChannel channel)
|
||||
{
|
||||
_db = db;
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
public async Task<NotificationResult> SendLineAsync(string body, int[] memberIds, int[] groupIds,
|
||||
string sentByUserId, CancellationToken ct = default)
|
||||
{
|
||||
var failures = new List<NotificationFailure>();
|
||||
var sentCount = 0;
|
||||
|
||||
var bindings = await _db.MemberChannelBindings
|
||||
.Where(b => b.Channel == Channel && memberIds.Contains(b.MemberId))
|
||||
.ToListAsync(ct);
|
||||
foreach (var binding in bindings)
|
||||
{
|
||||
var result = await _channel.PushToUserAsync(binding.ExternalId, body, ct);
|
||||
_db.NotificationLogs.Add(BuildLog(NotificationTargetTypes.User, binding.ExternalId,
|
||||
body, sentByUserId, result, memberId: binding.MemberId));
|
||||
if (result.Success) sentCount++;
|
||||
else failures.Add(new NotificationFailure($"member:{binding.MemberId}", result.Error ?? "unknown"));
|
||||
}
|
||||
|
||||
var groups = await _db.MessagingGroups
|
||||
.Where(g => g.Channel == Channel && g.IsActive && groupIds.Contains(g.Id))
|
||||
.ToListAsync(ct);
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var result = await _channel.PushToGroupAsync(group.ExternalId, body, ct);
|
||||
_db.NotificationLogs.Add(BuildLog(NotificationTargetTypes.Group, group.ExternalId,
|
||||
body, sentByUserId, result, groupId: group.Id));
|
||||
if (result.Success) sentCount++;
|
||||
else failures.Add(new NotificationFailure($"group:{group.Id}", result.Error ?? "unknown"));
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new NotificationResult(sentCount, failures.Count, failures);
|
||||
}
|
||||
|
||||
public async Task<string> GenerateLineBindingCodeAsync(int memberId, CancellationToken ct = default)
|
||||
{
|
||||
var code = GenerateCode();
|
||||
_db.LineBindingCodes.Add(new LineBindingCode
|
||||
{
|
||||
Code = code,
|
||||
MemberId = memberId,
|
||||
ExpiresAt = DateTime.UtcNow.Add(CodeLifetime),
|
||||
});
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return code;
|
||||
}
|
||||
|
||||
public async Task<LineBindingResult> TryBindMemberAsync(string externalId, string code, CancellationToken ct = default)
|
||||
{
|
||||
var normalized = code.Trim().ToUpperInvariant();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var bindingCode = await _db.LineBindingCodes
|
||||
.FirstOrDefaultAsync(c => c.Code == normalized && c.ConsumedAt == null && c.ExpiresAt > now, ct);
|
||||
if (bindingCode is null)
|
||||
return new LineBindingResult(false, "綁定碼無效或已過期。", null);
|
||||
|
||||
bindingCode.ConsumedAt = now;
|
||||
|
||||
var existing = await _db.MemberChannelBindings
|
||||
.FirstOrDefaultAsync(b => b.Channel == Channel && b.MemberId == bindingCode.MemberId, ct);
|
||||
if (existing is null)
|
||||
{
|
||||
_db.MemberChannelBindings.Add(new MemberChannelBinding
|
||||
{
|
||||
MemberId = bindingCode.MemberId,
|
||||
Channel = Channel,
|
||||
ExternalId = externalId,
|
||||
BoundAt = now,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.ExternalId = externalId;
|
||||
existing.BoundAt = now;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new LineBindingResult(true, "綁定成功!", bindingCode.MemberId);
|
||||
}
|
||||
|
||||
public async Task RegisterGroupAsync(string externalId, CancellationToken ct = default)
|
||||
{
|
||||
var group = await _db.MessagingGroups
|
||||
.FirstOrDefaultAsync(g => g.Channel == Channel && g.ExternalId == externalId, ct);
|
||||
if (group is null)
|
||||
{
|
||||
_db.MessagingGroups.Add(new MessagingGroup
|
||||
{
|
||||
Channel = Channel,
|
||||
ExternalId = externalId,
|
||||
IsActive = true,
|
||||
RegisteredAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
group.IsActive = true;
|
||||
}
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeactivateGroupAsync(string externalId, CancellationToken ct = default)
|
||||
{
|
||||
var group = await _db.MessagingGroups
|
||||
.FirstOrDefaultAsync(g => g.Channel == Channel && g.ExternalId == externalId, ct);
|
||||
if (group is not null)
|
||||
{
|
||||
group.IsActive = false;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static NotificationLog BuildLog(string targetType, string externalId, string body,
|
||||
string sentBy, MessageSendResult result, int? memberId = null, int? groupId = null) => new()
|
||||
{
|
||||
Channel = Channel,
|
||||
TargetType = targetType,
|
||||
TargetExternalId = externalId,
|
||||
MemberId = memberId,
|
||||
MessagingGroupId = groupId,
|
||||
Body = body,
|
||||
Status = result.Success ? NotificationStatuses.Sent : NotificationStatuses.Failed,
|
||||
Error = result.Error,
|
||||
SentByUserId = sentBy,
|
||||
SentAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
private static string GenerateCode()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(CodeLength);
|
||||
var chars = new char[CodeLength];
|
||||
for (var i = 0; i < CodeLength; i++)
|
||||
chars[i] = CodeAlphabet[bytes[i] % CodeAlphabet.Length];
|
||||
return new string(chars);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user