- 将通用 Entities 命名空间拆分为具体业务实体子目录 - 更新所有实体类型引用为具体的 Entity 后缀类型 - 将 ModelType、ModelProvider、GroupMemberRole 等枚举重命名为 Enum 后缀 - 重构 AiModel 相关配置和映射逻辑适配新的实体结构 - 更新测试代码中的实体类型引用和枚举使用 - 调整应用层处理器中的实体创建和查询逻辑 - 修改 appsettings.json 中的基础设施主机配置为动态占位符模式
338 lines
14 KiB
C#
338 lines
14 KiB
C#
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("等待条件在超时内未满足");
|
||
}
|
||
}
|