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); } /// 向指定会话连续写入 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 = MessageContentType.Text, Content = $"history-{i}", CreatedAt = baseTime.AddSeconds(i), }); } await DB.Default.SaveAsync(docs); return docs.Select(d => d.ID).ToList(); } 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("等待条件在超时内未满足"); } }