using IM.Application.Chat.DTOs; using IM.Domain.Common; using IM.Domain.Enums; using IM.Infrastructure.Mongo; using IM.Infrastructure.Persistence; using MongoDB.Entities; using Xunit; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace BuildFlow.IM.Integration.Tests; /// /// ChatHub 消息收发端到端用例。覆盖发送 → 广播 → 落库 → 未读累加的核心闭环。 /// [Xunit.Collection(ImServerCollection.Name)] public class ChatHubMessagingTests : IAsyncLifetime { private readonly ImServerFixture _fixture; private readonly List _connections = new(); public ChatHubMessagingTests(ImServerFixture fixture) => _fixture = fixture; public Task InitializeAsync() => _fixture.CleanRedisAsync(); public async Task DisposeAsync() { foreach (var conn in _connections) { try { await conn.DisposeAsync(); } catch { /* 忽略清理异常 */ } } } /// 构造一个已连接的 Hub 连接,并在用例结束时自动回收。 private async Task ConnectAsync(Guid userId, string userName) { var conn = HubConnectionFactory.Create(_fixture, userId, userName); await conn.StartAsync(); _connections.Add(conn); return conn; } [Fact] public async Task SendMessage_broadcasts_to_both_participants_and_persists_to_mongo() { // Arrange:私聊会话 Alice ↔ Bob,两者均在连接中 _fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice"); _fixture.UserInfoService.SetUser(ImServerFixture.BobId, "Bob"); var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId); var alice = await ConnectAsync(ImServerFixture.AliceId, "Alice"); var bob = await ConnectAsync(ImServerFixture.BobId, "Bob"); MessageDto? aliceReceived = null; MessageDto? bobReceived = null; alice.On("OnMessageReceived", m => aliceReceived = m); bob.On("OnMessageReceived", m => bobReceived = m); // Act:Alice 发一条文本消息 await alice.InvokeAsync("SendMessage", new SendMessageRequest( convId, MessageContentType.Text, "hello integration", null, null)); // 广播为异步,轮询等待两端均收到 await WaitForAsync(() => aliceReceived is not null && bobReceived is not null); // Assert ①:两参与者都收到了同一消息,正文/类型正确 Assert.NotNull(aliceReceived); Assert.NotNull(bobReceived); Assert.Equal("hello integration", aliceReceived!.Content); Assert.Equal(convId, bobReceived!.ConversationId); Assert.Equal(ImServerFixture.AliceId, aliceReceived.SenderId); // Assert ②:消息正文已落 MongoDB,可按会话查到(MongoDB.Entities 静态 DB.Default) var docs = await DB.Default.Find() .Match(m => m.ConversationId == convId) .ExecuteAsync(); var persisted = Assert.Single(docs); Assert.Equal("hello integration", persisted.Content); Assert.False(persisted.IsRecalled); // Assert ③:会话列表摘要已更新(PostgreSQL 冷数据) await using (var scope2 = _fixture.Services.CreateAsyncScope()) { var db = scope2.ServiceProvider.GetRequiredService(); var conv = await db.Conversations.AsNoTracking().FirstAsync(c => c.Id == convId); Assert.Equal("hello integration", conv.LastMessageContent); Assert.NotNull(conv.LastMessageId); Assert.NotNull(conv.LastMessageAt); } } [Fact] public async Task SendMessage_file_message_preserves_metadata_reply_and_updates_conversation_summary() { // Arrange:前端文件发送链路会先完成分片上传,再通过 ChatHub 发送文件名、大小和预览 URL。 _fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice"); _fixture.UserInfoService.SetUser(ImServerFixture.BobId, "Bob"); var convId = await ImTestSeeder.SeedPrivateConversationAsync( _fixture, ImServerFixture.AliceId, ImServerFixture.BobId); var quoted = new MessageDocument { ConversationId = convId, SenderId = ImServerFixture.BobId, SenderName = "Bob", ContentType = MessageContentType.Text, Content = "请把方案文件发我", CreatedAt = DateTime.UtcNow.AddSeconds(-10), }; await DB.Default.SaveAsync(quoted); var alice = await ConnectAsync(ImServerFixture.AliceId, "Alice"); var bob = await ConnectAsync(ImServerFixture.BobId, "Bob"); MessageDto? bobReceived = null; bob.On("OnMessageReceived", m => bobReceived = m); var metadata = new MessageMetadataDto( FileName: "BuildFlow-方案.pdf", FileSize: 2048, FileUrl: "/api/files/preview?objectKey=multipart%2Fhash", ImageUrl: null, ImageWidth: null, ImageHeight: null, EmojiCode: null); // Act:Alice 发送文件消息,并引用 Bob 的原始消息。 await alice.InvokeAsync("SendMessage", new SendMessageRequest( convId, MessageContentType.File, "BuildFlow-方案.pdf", metadata, quoted.ID)); await WaitForAsync(() => bobReceived is not null); // Assert ①:广播 DTO 保留文件元数据和引用消息 ID,前端可以直接渲染文件卡片与引用预览。 Assert.NotNull(bobReceived); Assert.Equal(MessageContentType.File, bobReceived!.ContentType); Assert.Equal("BuildFlow-方案.pdf", bobReceived.Content); Assert.Equal(quoted.ID, bobReceived.ReplyToId); Assert.Equal("BuildFlow-方案.pdf", bobReceived.Metadata?.FileName); Assert.Equal(2048, bobReceived.Metadata?.FileSize); Assert.Equal("/api/files/preview?objectKey=multipart%2Fhash", bobReceived.Metadata?.FileUrl); // Assert ②:MongoDB 持久化同样保留元数据和引用关系,支撑历史搜索/定位后的再次渲染。 var persisted = await DB.Default.Find() .Match(m => m.ID == bobReceived.Id) .ExecuteFirstAsync(); Assert.NotNull(persisted); Assert.Equal(quoted.ID, persisted!.ReplyToId); Assert.Equal("BuildFlow-方案.pdf", persisted.Metadata?.FileName); Assert.Equal("/api/files/preview?objectKey=multipart%2Fhash", persisted.Metadata?.FileUrl); // Assert ③:会话列表摘要使用文件占位,避免长 URL 或元数据污染列表。 await using var scope = _fixture.Services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var conv = await db.Conversations.AsNoTracking().FirstAsync(c => c.Id == convId); Assert.Equal($"[文件] {metadata.FileName}", conv.LastMessageContent); Assert.Equal(bobReceived.Id, conv.LastMessageId); } [Fact] public async Task SendMessage_increments_redis_unread_for_other_participant_only() { // Arrange:Alice、Bob、Carol 三人群聊。发消息时只给除发送者外的两人累加未读 _fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice"); var (convId, _) = await ImTestSeeder.SeedGroupConversationAsync( _fixture, "三人测试群", ImServerFixture.AliceId, (ImServerFixture.AliceId, GroupMemberRole.Owner), (ImServerFixture.BobId, GroupMemberRole.Member), (ImServerFixture.CarolId, GroupMemberRole.Member)); var alice = await ConnectAsync(ImServerFixture.AliceId, "Alice"); // Act:Alice 发一条消息 await alice.InvokeAsync("SendMessage", new SendMessageRequest( convId, MessageContentType.Text, "group hi", null, null)); // Assert:Redis 未读计数 Bob=1、Carol=1,发送者 Alice 无未读键 await using var scope = _fixture.Services.CreateAsyncScope(); var unread = scope.ServiceProvider.GetRequiredService(); var bobUnread = await unread.GetAsync(convId, ImServerFixture.BobId); var carolUnread = await unread.GetAsync(convId, ImServerFixture.CarolId); var aliceUnread = await unread.GetAsync(convId, ImServerFixture.AliceId); Assert.Equal(1, bobUnread); Assert.Equal(1, carolUnread); // 发送者不计未读:Redis 中该字段为空,GetAsync 返回 null Assert.Null(aliceUnread); } [Fact] public async Task SendMessage_triggers_offline_notification_for_offline_recipients() { // Arrange:Bob 离线(不连接)、Carol 在线(连接)。离线触达仅应发给 Bob _fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice"); var (convId, _) = await ImTestSeeder.SeedGroupConversationAsync( _fixture, "离线通知群", ImServerFixture.AliceId, (ImServerFixture.AliceId, GroupMemberRole.Owner), (ImServerFixture.BobId, GroupMemberRole.Member), (ImServerFixture.CarolId, GroupMemberRole.Member)); var alice = await ConnectAsync(ImServerFixture.AliceId, "Alice"); _ = await ConnectAsync(ImServerFixture.CarolId, "Carol"); // Carol 在线 _fixture.NotificationPublisher.Reset(); // Act await alice.InvokeAsync("SendMessage", new SendMessageRequest( convId, MessageContentType.Text, "ping offline bob", null, null)); // 通知发布为异步,等待 fake 收到一次调用 await WaitForAsync(() => { lock (_fixture.NotificationPublisher.Calls) return _fixture.NotificationPublisher.Calls.Count > 0; }); // Assert:仅有 Bob 收到离线通知,Carol 因在线被过滤 FakeImNotificationPublisher.Call call; lock (_fixture.NotificationPublisher.Calls) call = _fixture.NotificationPublisher.Calls[0]; Assert.Equal("im-message", call.Type); Assert.Equal(convId, call.ConversationId); var recipient = Assert.Single(call.Recipients); Assert.Equal(ImServerFixture.BobId, recipient); } [Fact] public async Task Typing_broadcasts_OnUserTyping_to_participants() { // Arrange _fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice"); _fixture.UserInfoService.SetUser(ImServerFixture.BobId, "Bob"); var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId); var alice = await ConnectAsync(ImServerFixture.AliceId, "Alice"); var bob = await ConnectAsync(ImServerFixture.BobId, "Bob"); Guid? typingUserId = null; bob.On("OnUserTyping", payload => typingUserId = payload.UserId); // Act await alice.InvokeAsync("Typing", convId); // Assert:Bob 收到 Alice 的输入状态 await WaitForAsync(() => typingUserId is not null); Assert.Equal(ImServerFixture.AliceId, typingUserId); } /// 轮询等待条件成立,避免固定 sleep。超时抛异常以便定位卡死。 private static async Task WaitForAsync(Func condition, TimeSpan? timeout = null) { var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(5)); while (DateTime.UtcNow < deadline) { if (condition()) return; await Task.Delay(25); } throw new TimeoutException("等待条件在超时内未满足"); } /// 异步条件重载:谓词返回 Task<bool>,用于轮询 Redis 等 IO 状态。 private static async Task WaitForAsync(Func> condition, TimeSpan? timeout = null) { var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(5)); while (DateTime.UtcNow < deadline) { if (await condition()) return; await Task.Delay(25); } throw new TimeoutException("等待条件在超时内未满足"); } }