using FluentAssertions; using Microsoft.EntityFrameworkCore; using RAG.Application.Notifications; using RAG.Application.Notifications.Commands; using RAG.Domain.Entities; using RAG.Infrastructure.Persistence; using Xunit; namespace RAG.Application.Tests.Notifications; /// /// 验证消息中心 Fanout 扇出策略:一条业务消息为每个接收人生成一条独立记录, /// 支持按用户 ID 精确指定与按角色展开,去重后落库。 /// public class PublishNotificationCommandTests { private static async Task<(RagDbContext db, PublishNotificationCommandHandler handler, List<(Guid id, string role)> users)> SetupAsync(string? roleName = "Admin") { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase($"notifications-{Guid.NewGuid()}") .Options; var db = new RagDbContext(options); var dispatcher = new NoOpDispatcher(); var handler = new PublishNotificationCommandHandler(db, dispatcher); // 构造角色 + 用户(用于角色展开测试)。审计字段在测试中手动填充(生产由 AuditInterceptor 填充) var role = Audit(new Role { Id = Guid.NewGuid(), Name = roleName ?? "Admin" }); var user1 = Audit(new User { Id = Guid.NewGuid(), Username = "u1", Email = "u1@e.com", PasswordHash = "x" }); var user2 = Audit(new User { Id = Guid.NewGuid(), Username = "u2", Email = "u2@e.com", PasswordHash = "x" }); db.Roles.Add(role); db.Users.AddRange(user1, user2); db.UserRoles.AddRange( Audit(new UserRole { UserId = user1.Id, RoleId = role.Id }), Audit(new UserRole { UserId = user2.Id, RoleId = role.Id })); await db.SaveChangesAsync(); return (db, handler, new() { (user1.Id, roleName ?? "Admin"), (user2.Id, roleName ?? "Admin") }); } /// 填充 IAuditable / IHasOperatorIP 审计字段(测试中绕过 AuditInterceptor)。 private static T Audit(T entity) where T : RAG.Domain.Common.IAuditable { var now = DateTime.UtcNow; entity.CreatedBy = "test"; entity.CreatedAt = now; entity.UpdatedBy = "test"; entity.UpdatedAt = now; // IFullAudit 实体还要求 OperatorIP 非空 if (entity is RAG.Domain.Common.IHasOperatorIP hasIp) hasIp.OperatorIP = "127.0.0.1"; return entity; } [Fact] public async Task Publish_with_explicit_user_ids_creates_one_record_per_recipient() { var (db, handler, users) = await SetupAsync(); var recipientIds = users.Select(u => u.id).ToList(); var count = await handler.Handle(new PublishNotificationCommand( Type: "system", Title: "你好", Content: "测试通知", Source: "test", RelatedId: null, RelatedType: null, RecipientUserIds: recipientIds, RecipientRoles: null), default); count.Should().Be(2); var notifications = await db.Notifications.ToListAsync(); notifications.Should().HaveCount(2); notifications.Select(n => n.RecipientUserId).Should().BeEquivalentTo(recipientIds); notifications.All(n => !n.IsRead && n.Title == "你好").Should().BeTrue(); } [Fact] public async Task Publish_by_role_expands_to_all_users_with_that_role() { var (db, handler, users) = await SetupAsync(roleName: "Admin"); var count = await handler.Handle(new PublishNotificationCommand( Type: "task-arrived", Title: "新待办", Content: "请处理", Source: "workflow", RelatedId: "inst-123", RelatedType: "workflow-instance", RecipientUserIds: null, RecipientRoles: new List { "Admin" }), default); count.Should().Be(2); var notifications = await db.Notifications.ToListAsync(); notifications.Should().HaveCount(2); notifications.All(n => n.Source == "workflow" && n.RelatedId == "inst-123").Should().BeTrue(); } [Fact] public async Task Publish_dedupes_when_user_both_in_ids_and_role() { var (db, handler, users) = await SetupAsync(roleName: "Admin"); var firstUser = users[0].id; // 该用户既在 RecipientUserIds 又拥有 Admin 角色 → 只落一条 var count = await handler.Handle(new PublishNotificationCommand( Type: "system", Title: "去重测试", Content: "只一条", Source: "test", RelatedId: null, RelatedType: null, RecipientUserIds: new List { firstUser }, RecipientRoles: new List { "Admin" }), default); // 两个 Admin 用户都该收到 = 2 条(去重发生在同一用户内) count.Should().Be(2); var notifications = await db.Notifications.Where(n => n.RecipientUserId == firstUser).ToListAsync(); notifications.Should().HaveCount(1); } [Fact] public async Task Publish_with_no_recipients_returns_zero() { var (db, handler, _) = await SetupAsync(); var count = await handler.Handle(new PublishNotificationCommand( Type: "system", Title: "无人接收", Content: "x", Source: "test", RelatedId: null, RelatedType: null, RecipientUserIds: null, RecipientRoles: null), default); count.Should().Be(0); (await db.Notifications.ToListAsync()).Should().BeEmpty(); } /// 空操作分发器:测试中不依赖 SignalR,只验证落库。 private sealed class NoOpDispatcher : INotificationDispatcher { public Task DispatchAsync(IReadOnlyList notifications, CancellationToken ct = default) => Task.CompletedTask; } }