using IM.Application.Chat.Commands; using IM.Application.Chat.Handlers; using IM.Domain.Common; using IM.Domain.Enums; using IM.Infrastructure.Mongo; using IM.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using MongoDB.Driver; using MongoDB.Entities; using Moq; using Xunit; namespace BuildFlow.IM.Tests.Chat; /// /// SendMessageCommandHandler 的核心断言:未读数累加已移出 PostgreSQL 事务,改走 IUnreadCounter(Redis)。 /// public class SendMessageCommandHandlerTests { /// 本地测试 MongoDB 连接串(与集成测试共用,需本地 docker 起 mongo)。 private const string MongoConnection = "mongodb://mongo:mongo123@localhost:27017/im_unit_test?authSource=admin"; static SendMessageCommandHandlerTests() { // MongoDB.Entities 用静态 DB.Default,进程内一次性初始化(幂等)。 // 消息正文落真实 Mongo,但本测试仅断言未读数语义,不校验 Mongo 内容。 var dbName = MongoUrl.Create(MongoConnection).DatabaseName!; DB.InitAsync(dbName, MongoClientSettings.FromConnectionString(MongoConnection)).GetAwaiter().GetResult(); } private static readonly Guid SenderId = Guid.Parse("22222222-2222-2222-2222-222222222222"); private static readonly Guid RecipientA = Guid.Parse("30000000-0000-0000-0000-000000000001"); private static readonly Guid RecipientB = Guid.Parse("30000000-0000-0000-0000-000000000002"); private static readonly Guid ConversationId = Guid.Parse("11111111-1111-1111-1111-111111111111"); [Fact] public async Task Does_not_touch_pg_unread_count_and_calls_redis_increment_for_others() { // Arrange:构造一个含发送者+两个接收者的会话(接收者带初始未读数,验证 Handler 不再原地 +1) await using var db = BuildInMemoryDb(); db.Conversations.Add(ImTestData.BuildConversation(ConversationId, ConversationType.Group, (SenderId, 0), (RecipientA, 5), (RecipientB, 0))); await db.SaveChangesAsync(); var (handler, unreadCounter, _, _, _) = BuildHandler(db); // Act await handler.Handle(new SendMessageCommand( ConversationId, SenderId, "alice", MessageContentType.Text, "hello", null, null), CancellationToken.None); // Assert ①:PG 中接收者的未读数保持原值(未被 Handler 修改) var aAfter = await db.ConversationParticipants.FirstAsync(p => p.UserId == RecipientA); var bAfter = await db.ConversationParticipants.FirstAsync(p => p.UserId == RecipientB); Assert.Equal(5, aAfter.UnreadCount); Assert.Equal(0, bAfter.UnreadCount); // Assert ②:Redis 未读累加被调用一次,且参数恰好是两个接收者(排除发送者) unreadCounter.Verify( c => c.IncrementAsync(ConversationId, It.Is>(ids => ids.Order().SequenceEqual(new[] { RecipientA, RecipientB }.Order())), It.IsAny()), Times.Once); } [Fact] public async Task Skips_redis_increment_when_no_other_participants() { await using var db = BuildInMemoryDb(); db.Conversations.Add(ImTestData.BuildConversation(ConversationId, ConversationType.Private, (SenderId, 0))); await db.SaveChangesAsync(); var (handler, unreadCounter, _, _, _) = BuildHandler(db); await handler.Handle(new SendMessageCommand( ConversationId, SenderId, "alice", MessageContentType.Text, "solo", null, null), CancellationToken.None); // 没有其他参与者时,不应触发 Redis 累加 unreadCounter.Verify( c => c.IncrementAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); } [Fact] public async Task Updates_conversation_last_message_summary() { await using var db = BuildInMemoryDb(); db.Conversations.Add(ImTestData.BuildConversation(ConversationId, ConversationType.Private, (SenderId, 0), (RecipientA, 0))); await db.SaveChangesAsync(); var (handler, _, _, _, _) = BuildHandler(db); await handler.Handle(new SendMessageCommand( ConversationId, SenderId, "alice", MessageContentType.Image, "[图片]", null, null), CancellationToken.None); var conv = await db.Conversations.FirstAsync(c => c.Id == ConversationId); Assert.Equal("[图片]", conv.LastMessageContent); Assert.NotNull(conv.LastMessageId); Assert.NotNull(conv.LastMessageAt); } [Fact] public async Task Publishes_notification_to_offline_recipients_only() { // Arrange:RecipientA 离线、RecipientB 在线 await using var db = BuildInMemoryDb(); db.Conversations.Add(ImTestData.BuildConversation(ConversationId, ConversationType.Group, (SenderId, 0), (RecipientA, 0), (RecipientB, 0))); await db.SaveChangesAsync(); var (handler, _, _, onlinePresence, notification) = BuildHandler(db); onlinePresence.Setup(o => o.FilterOfflineAsync(It.IsAny>(), It.IsAny())) .ReturnsAsync((IEnumerable ids, CancellationToken _) => ids.Where(id => id == RecipientA).ToHashSet()); // Act await handler.Handle(new SendMessageCommand( ConversationId, SenderId, "alice", MessageContentType.Text, "hello", null, null), CancellationToken.None); // Assert:仅对离线的 RecipientA 发通知(Source=im),在线的 RecipientB 不在收件人列表 notification.Verify( n => n.PublishAsync( "im-message", It.Is(t => t.Contains("alice")), "hello", ConversationId, It.Is>(ids => ids.SequenceEqual(new[] { RecipientA })), It.IsAny()), Times.Once); } [Fact] public async Task Skips_notification_when_all_recipients_online() { await using var db = BuildInMemoryDb(); db.Conversations.Add(ImTestData.BuildConversation(ConversationId, ConversationType.Private, (SenderId, 0), (RecipientA, 0))); await db.SaveChangesAsync(); var (handler, _, _, onlinePresence, notification) = BuildHandler(db); // 全部在线:FilterOfflineAsync 返回空集合 onlinePresence.Setup(o => o.FilterOfflineAsync(It.IsAny>(), It.IsAny())) .ReturnsAsync(new HashSet()); await handler.Handle(new SendMessageCommand( ConversationId, SenderId, "alice", MessageContentType.Text, "hello", null, null), CancellationToken.None); // 所有接收者在线时,不触发任何站内信 notification.Verify( n => n.PublishAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); } /// /// 构造 Handler。Mongo 消息正文落真实库(静态 DB.Default 已在静态构造函数初始化), /// 此处聚焦未读数语义。默认所有接收者视为离线(驱动通知链路),个别测试可覆盖。 /// private static (SendMessageCommandHandler Handler, Mock UnreadCounter, Mock UserInfo, Mock OnlinePresence, Mock Notification) BuildHandler(ImDbContext db) { var unreadCounter = new Mock(); var userInfo = new Mock(); userInfo.Setup(u => u.GetUsersAsync(It.IsAny>(), It.IsAny())) .ReturnsAsync(new Dictionary()); // 默认全部离线(返回传入集合本身),驱动通知触发 var onlinePresence = new Mock(); onlinePresence.Setup(o => o.FilterOfflineAsync(It.IsAny>(), It.IsAny())) .ReturnsAsync((IEnumerable ids, CancellationToken _) => ids.ToHashSet()); var notification = new Mock(); var handler = new SendMessageCommandHandler( db, userInfo.Object, unreadCounter.Object, onlinePresence.Object, notification.Object); return (handler, unreadCounter, userInfo, onlinePresence, notification); } private static ImDbContext BuildInMemoryDb() { var services = new ServiceCollection(); services.AddDbContext(o => o.UseInMemoryDatabase($"im-test-{Guid.NewGuid()}")); var db = services.BuildServiceProvider().GetRequiredService(); return db; } }