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 = MessageContentType.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 = MessageContentType.Text,
Content = "被引用的原始消息",
CreatedAt = baseTime,
};
await DB.Default.SaveAsync(quoted);
var beforeTarget = new MessageDocument
{
ConversationId = convId,
SenderId = ImServerFixture.AliceId,
SenderName = "Alice",
ContentType = MessageContentType.Text,
Content = "定位窗口前一条",
CreatedAt = baseTime.AddSeconds(10),
};
var target = new MessageDocument
{
ConversationId = convId,
SenderId = ImServerFixture.AliceId,
SenderName = "Alice",
ContentType = MessageContentType.Text,
Content = "回复引用消息的搜索命中",
ReplyToId = quoted.ID,
CreatedAt = baseTime.AddSeconds(20),
};
var afterTarget = new MessageDocument
{
ConversationId = convId,
SenderId = ImServerFixture.BobId,
SenderName = "Bob",
ContentType = MessageContentType.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 = 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<(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 = MessageContentType.Text,
Content = "讨论 BuildFlow 搜索定位",
CreatedAt = baseTime,
};
await DB.Default.SaveAsync(textMatch);
var senderMatch = new MessageDocument
{
ConversationId = conversationId,
SenderId = ImServerFixture.BobId,
SenderName = "BuildFlow Bob",
ContentType = MessageContentType.Text,
Content = "按发送者名称命中",
CreatedAt = baseTime.AddSeconds(10),
};
var fileMatch = new MessageDocument
{
ConversationId = conversationId,
SenderId = ImServerFixture.AliceId,
SenderName = "Alice",
ContentType = MessageContentType.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("等待条件在超时内未满足");
}
}