build-flow.api/tests/BuildFlow.IM.Integration.Tests/ChatHubAuthAndHistoryTests.cs
xiangning 2181f7dd49 feat(platform): 完善平台功能与权限管理
- 在菜单系统中新增 RenderType、FormDefinitionId 和 RuntimeViewMode 字段
- 实现自定义表单菜单类型的组件映射和验证逻辑
- 为分片上传相关端点添加 file:upload 权限控制
- 为桶和文件夹操作添加 bucket:write 和 file:write 权限控制
- 为文件共享功能添加 file-share:write 权限控制
- 为文件读取操作添加 file:read 权限控制
- 扩展 AI 模型提供商枚举,支持更多模型供应商
- 在 AI 模型创建和更新时添加枚举值验证
- 重构工作流按钮权限检查逻辑,使用统一的权限码查询方式
- 为 JSON 格式文档添加提取器支持
- 增强表单数据派生服务,支持自定义表单关联解析
- 为表单数据添加按表单定义 ID 的索引优化
- 添加聊天消息搜索和上下文获取的集成测试
- 修正删除菜单端点的 ID 类型从 Guid 改为 long
2026-07-04 19:38:43 +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 = MessageContentType.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 = 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<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 = MessageContentType.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 = 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<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("等待条件在超时内未满足");
}
}