build-flow.api/tests/BuildFlow.IM.Integration.Tests/ChatHubAuthAndHistoryTests.cs
xiangning 3b13099378 refactor(domain): 重构领域实体结构并更新枚举命名
- 将通用 Entities 命名空间拆分为具体业务实体子目录
- 更新所有实体类型引用为具体的 Entity 后缀类型
- 将 ModelType、ModelProvider、GroupMemberRole 等枚举重命名为 Enum 后缀
- 重构 AiModel 相关配置和映射逻辑适配新的实体结构
- 更新测试代码中的实体类型引用和枚举使用
- 调整应用层处理器中的实体创建和查询逻辑
- 修改 appsettings.json 中的基础设施主机配置为动态占位符模式
2026-07-19 14:02:32 +08:00

338 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <summary>
/// 权限隔离与消息历史分页用例。
/// 通过 MediatR 直接驱动查询/命令处理器,并验证 ChatHub 的非法访问会被 query 的鉴权副作用拦截。
/// </summary>
[Xunit.Collection(ImServerCollection.Name)]
public class ChatHubAuthAndHistoryTests : IAsyncLifetime
{
private readonly ImServerFixture _fixture;
private readonly List<HubConnection> _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<HubConnection> 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<HubException>(() =>
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<IMediator>();
// Act / Assert:非参与者查询参与者列表抛 UnauthorizedException
await Assert.ThrowsAsync<BuildFlow.Shared.Domain.Exceptions.UnauthorizedException>(() =>
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<IMediator>();
// Act / Assert:非参与者拉取历史抛 UnauthorizedException
await Assert.ThrowsAsync<BuildFlow.Shared.Domain.Exceptions.UnauthorizedException>(() =>
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<IMediator>();
// 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<IMediator>();
// 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<IMediator>();
// Act / Assert:非参与者不能搜索历史消息。
await Assert.ThrowsAsync<BuildFlow.Shared.Domain.Exceptions.UnauthorizedException>(() =>
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<IMediator>();
// 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<IMediator>();
// 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);
}
/// <summary>向指定会话连续写入 N 条消息,返回按插入顺序的 ObjectId 列表。</summary>
private async Task<List<string>> SeedMessagesAsync(Guid conversationId, int count)
{
var docs = new List<MessageDocument>();
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();
}
/// <summary>准备历史搜索的三类命中样本:正文、发送者名称、文件名,并返回持久化后的文档引用。</summary>
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<bool> 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("等待条件在超时内未满足");
}
}