using IM.Application.Chat.Queries; using IM.Domain.Enums; using IM.Infrastructure.Mongo; using MongoDB.Entities; using Xunit; using Microsoft.AspNetCore.SignalR.Client; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; using MediatR; namespace BuildFlow.IM.Integration.Tests; /// /// 权限隔离与消息历史分页用例。 /// 通过 MediatR 直接驱动查询/命令处理器,并验证 ChatHub 的非法访问会被 query 的鉴权副作用拦截。 /// [Xunit.Collection(ImServerCollection.Name)] public class ChatHubAuthAndHistoryTests : IAsyncLifetime { private readonly ImServerFixture _fixture; private readonly List _connections = new(); public ChatHubAuthAndHistoryTests(ImServerFixture fixture) => _fixture = fixture; public Task InitializeAsync() => _fixture.CleanRedisAsync(); public async Task DisposeAsync() { foreach (var conn in _connections) { try { await conn.DisposeAsync(); } catch { } } } 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 Non_participant_cannot_join_conversation_group() { // Arrange:Alice 与 Bob 的私聊,Outsider 不参与 _fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice"); _fixture.UserInfoService.SetUser(ImServerFixture.BobId, "Bob"); var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId); var outsider = await ConnectAsync(ImServerFixture.OutSiderId, "Outsider"); // Act / Assert:Outsider 调 JoinConversation 应被鉴权拦截(Hub 抛异常) // SignalR 客户端会把 hub 抛出的异常转为 HubException var ex = await Assert.ThrowsAsync(() => outsider.InvokeAsync("JoinConversation", convId)); Assert.Contains("无权访问该会话", ex.Message); } [Fact] public async Task Get_conversation_participants_rejects_non_participant() { // Arrange var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId); await using var scope = _fixture.Services.CreateAsyncScope(); var mediator = scope.ServiceProvider.GetRequiredService(); // Act / Assert:非参与者查询参与者列表抛 UnauthorizedException await Assert.ThrowsAsync(() => mediator.Send(new GetConversationParticipantIdsQuery(convId, ImServerFixture.OutSiderId))); } [Fact] public async Task Get_message_history_rejects_non_participant() { // Arrange var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId); await using var scope = _fixture.Services.CreateAsyncScope(); var mediator = scope.ServiceProvider.GetRequiredService(); // Act / Assert:非参与者拉取历史抛 UnauthorizedException await Assert.ThrowsAsync(() => mediator.Send(new GetMessageHistoryQuery(ImServerFixture.OutSiderId, convId, null, 20))); } [Fact] public async Task Message_history_cursor_pagination_returns_correct_page_and_hasmore() { // Arrange:直接落多条消息到 MongoDB(绕过 Hub,聚焦分页逻辑) _fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice"); var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId); var messageIds = await SeedMessagesAsync(convId, count: 5); await using var scope = _fixture.Services.CreateAsyncScope(); var mediator = scope.ServiceProvider.GetRequiredService(); // Act ①:第一页取 3 条(Limit+1 → 实际查 4 条,应判定 hasMore=true) var page1 = await mediator.Send(new GetMessageHistoryQuery(ImServerFixture.AliceId, convId, null, 3)); // Assert:hasMore 为 true,返回恰好 3 条,且按时间倒序(最新在前) Assert.True(page1.HasMore); Assert.Equal(3, page1.Messages.Count); Assert.Equal(messageIds[4], page1.Messages[0].Id); // 最新的在第 5 个位置插入 // Act ②:以第一页最后一条为游标取下一页(取 3 条,剩 2 条 → hasMore=false) var cursor = page1.Messages[^1].Id; var page2 = await mediator.Send(new GetMessageHistoryQuery(ImServerFixture.AliceId, convId, cursor, 3)); // Assert:剩余 2 条,无更多 Assert.False(page2.HasMore); Assert.Equal(2, page2.Messages.Count); } [Fact] public async Task Search_messages_finds_text_sender_and_file_name_with_reply_metadata() { // Arrange:同一会话内准备正文、发送者、文件名三类命中;另一个会话放同关键字验证会话隔离。 _fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice"); _fixture.UserInfoService.SetUser(ImServerFixture.BobId, "BuildFlow Bob"); var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId); var otherConvId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.CarolId); var docs = await SeedSearchMessagesAsync(convId); await DB.Default.SaveAsync(new MessageDocument { ConversationId = otherConvId, SenderId = ImServerFixture.AliceId, SenderName = "Alice", ContentType = MessageContentTypeEnum.Text, Content = "BuildFlow in another conversation", CreatedAt = DateTime.UtcNow, }); await using var scope = _fixture.Services.CreateAsyncScope(); var mediator = scope.ServiceProvider.GetRequiredService(); // Act:搜索 BuildFlow,应命中正文、发送者名称和文件名。 var result = await mediator.Send(new SearchMessagesQuery( ImServerFixture.AliceId, convId, "BuildFlow", 10)); // Assert:仅返回当前会话命中,按时间倒序;引用 ID 与文件名元数据保留给前端渲染引用/文件卡片。 Assert.False(result.HasMore); Assert.Equal(3, result.Messages.Count); Assert.Equal(new[] { docs.FileMatch.ID, docs.SenderMatch.ID, docs.TextMatch.ID }, result.Messages.Select(m => m.Id)); Assert.Equal(docs.TextMatch.ID, result.Messages[0].ReplyToId); Assert.Equal("BuildFlow-方案.pdf", result.Messages[0].Metadata?.FileName); } [Fact] public async Task Search_messages_rejects_non_participant() { // Arrange var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId); await using var scope = _fixture.Services.CreateAsyncScope(); var mediator = scope.ServiceProvider.GetRequiredService(); // Act / Assert:非参与者不能搜索历史消息。 await Assert.ThrowsAsync(() => mediator.Send(new SearchMessagesQuery(ImServerFixture.OutSiderId, convId, "BuildFlow", 10))); } [Fact] public async Task Get_messages_around_returns_ordered_context_window() { // Arrange:写 5 条连续历史,定位第 3 条,要求返回前后各 1 条并按时间正序。 _fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice"); var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId); var messageIds = await SeedMessagesAsync(convId, count: 5); await using var scope = _fixture.Services.CreateAsyncScope(); var mediator = scope.ServiceProvider.GetRequiredService(); // Act var result = await mediator.Send(new GetMessagesAroundQuery( ImServerFixture.AliceId, convId, messageIds[2], 1)); // Assert:返回 older + target + newer,顺序适合前端直接替换当前上下文并滚动定位。 Assert.True(result.HasMore); Assert.Equal(new[] { messageIds[1], messageIds[2], messageIds[3] }, result.Messages.Select(m => m.Id)); } [Fact] public async Task Get_messages_around_includes_referenced_message_outside_context_window() { // Arrange:目标消息引用了更早的消息,但该引用消息不在前后 1 条上下文窗口内。 _fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice"); var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId); var baseTime = DateTime.UtcNow.AddMinutes(-4); var quoted = new MessageDocument { ConversationId = convId, SenderId = ImServerFixture.BobId, SenderName = "Bob", ContentType = MessageContentTypeEnum.Text, Content = "被引用的原始消息", CreatedAt = baseTime, }; await DB.Default.SaveAsync(quoted); var beforeTarget = new MessageDocument { ConversationId = convId, SenderId = ImServerFixture.AliceId, SenderName = "Alice", ContentType = MessageContentTypeEnum.Text, Content = "定位窗口前一条", CreatedAt = baseTime.AddSeconds(10), }; var target = new MessageDocument { ConversationId = convId, SenderId = ImServerFixture.AliceId, SenderName = "Alice", ContentType = MessageContentTypeEnum.Text, Content = "回复引用消息的搜索命中", ReplyToId = quoted.ID, CreatedAt = baseTime.AddSeconds(20), }; var afterTarget = new MessageDocument { ConversationId = convId, SenderId = ImServerFixture.BobId, SenderName = "Bob", ContentType = MessageContentTypeEnum.Text, Content = "定位窗口后一条", CreatedAt = baseTime.AddSeconds(30), }; await DB.Default.SaveAsync(new[] { beforeTarget, target, afterTarget }); await using var scope = _fixture.Services.CreateAsyncScope(); var mediator = scope.ServiceProvider.GetRequiredService(); // Act var result = await mediator.Send(new GetMessagesAroundQuery( ImServerFixture.AliceId, convId, target.ID, 1)); // Assert:引用消息被额外补入,前端搜索定位后可以直接渲染引用预览。 Assert.Equal( new[] { quoted.ID, beforeTarget.ID, target.ID, afterTarget.ID }, result.Messages.Select(m => m.Id)); Assert.Equal(quoted.ID, result.Messages.Single(m => m.Id == target.ID).ReplyToId); } /// 向指定会话连续写入 N 条消息,返回按插入顺序的 ObjectId 列表。 private async Task> SeedMessagesAsync(Guid conversationId, int count) { var docs = new List(); var baseTime = DateTime.UtcNow.AddMinutes(-count); for (var i = 0; i < count; i++) { docs.Add(new MessageDocument { ConversationId = conversationId, SenderId = ImServerFixture.AliceId, SenderName = "Alice", ContentType = MessageContentTypeEnum.Text, Content = $"history-{i}", CreatedAt = baseTime.AddSeconds(i), }); } await DB.Default.SaveAsync(docs); return docs.Select(d => d.ID).ToList(); } /// 准备历史搜索的三类命中样本:正文、发送者名称、文件名,并返回持久化后的文档引用。 private static async Task<(MessageDocument TextMatch, MessageDocument SenderMatch, MessageDocument FileMatch)> SeedSearchMessagesAsync(Guid conversationId) { var baseTime = DateTime.UtcNow.AddMinutes(-3); var textMatch = new MessageDocument { ConversationId = conversationId, SenderId = ImServerFixture.AliceId, SenderName = "Alice", ContentType = MessageContentTypeEnum.Text, Content = "讨论 BuildFlow 搜索定位", CreatedAt = baseTime, }; await DB.Default.SaveAsync(textMatch); var senderMatch = new MessageDocument { ConversationId = conversationId, SenderId = ImServerFixture.BobId, SenderName = "BuildFlow Bob", ContentType = MessageContentTypeEnum.Text, Content = "按发送者名称命中", CreatedAt = baseTime.AddSeconds(10), }; var fileMatch = new MessageDocument { ConversationId = conversationId, SenderId = ImServerFixture.AliceId, SenderName = "Alice", ContentType = MessageContentTypeEnum.File, Content = "BuildFlow-方案.pdf", Metadata = new MessageMetadata { FileName = "BuildFlow-方案.pdf", FileSize = 1024, FileUrl = "/api/files/preview?objectKey=multipart%2Fhash", ObjectKey = "multipart/hash", ContentType = "application/pdf", }, ReplyToId = textMatch.ID, CreatedAt = baseTime.AddSeconds(20), }; await DB.Default.SaveAsync(new[] { senderMatch, fileMatch }); return (textMatch, senderMatch, fileMatch); } 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("等待条件在超时内未满足"); } }