- 将通用 Entities 命名空间拆分为具体业务实体子目录 - 更新所有实体类型引用为具体的 Entity 后缀类型 - 将 ModelType、ModelProvider、GroupMemberRole 等枚举重命名为 Enum 后缀 - 重构 AiModel 相关配置和映射逻辑适配新的实体结构 - 更新测试代码中的实体类型引用和枚举使用 - 调整应用层处理器中的实体创建和查询逻辑 - 修改 appsettings.json 中的基础设施主机配置为动态占位符模式
276 lines
12 KiB
C#
276 lines
12 KiB
C#
using IM.Application.Chat.DTOs;
|
||
using IM.Domain.Common;
|
||
using IM.Domain.Enums;
|
||
using IM.Infrastructure.Mongo;
|
||
using IM.Infrastructure.Persistence;
|
||
using MongoDB.Entities;
|
||
using Xunit;
|
||
using Microsoft.AspNetCore.SignalR.Client;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.DependencyInjection;
|
||
|
||
namespace BuildFlow.IM.Integration.Tests;
|
||
|
||
/// <summary>
|
||
/// ChatHub 消息收发端到端用例。覆盖发送 → 广播 → 落库 → 未读累加的核心闭环。
|
||
/// </summary>
|
||
[Xunit.Collection(ImServerCollection.Name)]
|
||
public class ChatHubMessagingTests : IAsyncLifetime
|
||
{
|
||
private readonly ImServerFixture _fixture;
|
||
private readonly List<HubConnection> _connections = new();
|
||
|
||
public ChatHubMessagingTests(ImServerFixture fixture) => _fixture = fixture;
|
||
|
||
public Task InitializeAsync() => _fixture.CleanRedisAsync();
|
||
|
||
public async Task DisposeAsync()
|
||
{
|
||
foreach (var conn in _connections)
|
||
{
|
||
try { await conn.DisposeAsync(); } catch { /* 忽略清理异常 */ }
|
||
}
|
||
}
|
||
|
||
/// <summary>构造一个已连接的 Hub 连接,并在用例结束时自动回收。</summary>
|
||
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 SendMessage_broadcasts_to_both_participants_and_persists_to_mongo()
|
||
{
|
||
// Arrange:私聊会话 Alice ↔ Bob,两者均在连接中
|
||
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
|
||
_fixture.UserInfoService.SetUser(ImServerFixture.BobId, "Bob");
|
||
var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId);
|
||
|
||
var alice = await ConnectAsync(ImServerFixture.AliceId, "Alice");
|
||
var bob = await ConnectAsync(ImServerFixture.BobId, "Bob");
|
||
|
||
MessageDto? aliceReceived = null;
|
||
MessageDto? bobReceived = null;
|
||
alice.On<MessageDto>("OnMessageReceived", m => aliceReceived = m);
|
||
bob.On<MessageDto>("OnMessageReceived", m => bobReceived = m);
|
||
|
||
// Act:Alice 发一条文本消息
|
||
await alice.InvokeAsync("SendMessage", new SendMessageRequest(
|
||
convId, MessageContentTypeEnum.Text, "hello integration", null, null));
|
||
|
||
// 广播为异步,轮询等待两端均收到
|
||
await WaitForAsync(() => aliceReceived is not null && bobReceived is not null);
|
||
|
||
// Assert ①:两参与者都收到了同一消息,正文/类型正确
|
||
Assert.NotNull(aliceReceived);
|
||
Assert.NotNull(bobReceived);
|
||
Assert.Equal("hello integration", aliceReceived!.Content);
|
||
Assert.Equal(convId, bobReceived!.ConversationId);
|
||
Assert.Equal(ImServerFixture.AliceId, aliceReceived.SenderId);
|
||
|
||
// Assert ②:消息正文已落 MongoDB,可按会话查到(MongoDB.Entities 静态 DB.Default)
|
||
var docs = await DB.Default.Find<MessageDocument>()
|
||
.Match(m => m.ConversationId == convId)
|
||
.ExecuteAsync();
|
||
var persisted = Assert.Single(docs);
|
||
Assert.Equal("hello integration", persisted.Content);
|
||
Assert.False(persisted.IsRecalled);
|
||
|
||
// Assert ③:会话列表摘要已更新(PostgreSQL 冷数据)
|
||
await using (var scope2 = _fixture.Services.CreateAsyncScope())
|
||
{
|
||
var db = scope2.ServiceProvider.GetRequiredService<ImDbContext>();
|
||
var conv = await db.Conversations.AsNoTracking().FirstAsync(c => c.Id == convId);
|
||
Assert.Equal("hello integration", conv.LastMessageContent);
|
||
Assert.NotNull(conv.LastMessageId);
|
||
Assert.NotNull(conv.LastMessageAt);
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public async Task SendMessage_file_message_preserves_metadata_reply_and_updates_conversation_summary()
|
||
{
|
||
// Arrange:前端文件发送链路会先完成分片上传,再通过 ChatHub 发送文件名、大小和预览 URL。
|
||
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
|
||
_fixture.UserInfoService.SetUser(ImServerFixture.BobId, "Bob");
|
||
var convId = await ImTestSeeder.SeedPrivateConversationAsync(
|
||
_fixture,
|
||
ImServerFixture.AliceId,
|
||
ImServerFixture.BobId);
|
||
|
||
var quoted = new MessageDocument
|
||
{
|
||
ConversationId = convId,
|
||
SenderId = ImServerFixture.BobId,
|
||
SenderName = "Bob",
|
||
ContentType = MessageContentTypeEnum.Text,
|
||
Content = "请把方案文件发我",
|
||
CreatedAt = DateTime.UtcNow.AddSeconds(-10),
|
||
};
|
||
await DB.Default.SaveAsync(quoted);
|
||
|
||
var alice = await ConnectAsync(ImServerFixture.AliceId, "Alice");
|
||
var bob = await ConnectAsync(ImServerFixture.BobId, "Bob");
|
||
|
||
MessageDto? bobReceived = null;
|
||
bob.On<MessageDto>("OnMessageReceived", m => bobReceived = m);
|
||
|
||
var metadata = new MessageMetadataDto(
|
||
FileName: "BuildFlow-方案.pdf",
|
||
FileSize: 2048,
|
||
FileUrl: "/api/files/preview?objectKey=multipart%2Fhash",
|
||
ImageUrl: null,
|
||
ImageWidth: null,
|
||
ImageHeight: null,
|
||
EmojiCode: null);
|
||
|
||
// Act:Alice 发送文件消息,并引用 Bob 的原始消息。
|
||
await alice.InvokeAsync("SendMessage", new SendMessageRequest(
|
||
convId,
|
||
MessageContentTypeEnum.File,
|
||
"BuildFlow-方案.pdf",
|
||
metadata,
|
||
quoted.ID));
|
||
|
||
await WaitForAsync(() => bobReceived is not null);
|
||
|
||
// Assert ①:广播 DTO 保留文件元数据和引用消息 ID,前端可以直接渲染文件卡片与引用预览。
|
||
Assert.NotNull(bobReceived);
|
||
Assert.Equal(MessageContentTypeEnum.File, bobReceived!.ContentType);
|
||
Assert.Equal("BuildFlow-方案.pdf", bobReceived.Content);
|
||
Assert.Equal(quoted.ID, bobReceived.ReplyToId);
|
||
Assert.Equal("BuildFlow-方案.pdf", bobReceived.Metadata?.FileName);
|
||
Assert.Equal(2048, bobReceived.Metadata?.FileSize);
|
||
Assert.Equal("/api/files/preview?objectKey=multipart%2Fhash", bobReceived.Metadata?.FileUrl);
|
||
|
||
// Assert ②:MongoDB 持久化同样保留元数据和引用关系,支撑历史搜索/定位后的再次渲染。
|
||
var persisted = await DB.Default.Find<MessageDocument>()
|
||
.Match(m => m.ID == bobReceived.Id)
|
||
.ExecuteFirstAsync();
|
||
Assert.NotNull(persisted);
|
||
Assert.Equal(quoted.ID, persisted!.ReplyToId);
|
||
Assert.Equal("BuildFlow-方案.pdf", persisted.Metadata?.FileName);
|
||
Assert.Equal("/api/files/preview?objectKey=multipart%2Fhash", persisted.Metadata?.FileUrl);
|
||
|
||
// Assert ③:会话列表摘要使用文件占位,避免长 URL 或元数据污染列表。
|
||
await using var scope = _fixture.Services.CreateAsyncScope();
|
||
var db = scope.ServiceProvider.GetRequiredService<ImDbContext>();
|
||
var conv = await db.Conversations.AsNoTracking().FirstAsync(c => c.Id == convId);
|
||
Assert.Equal($"[文件] {metadata.FileName}", conv.LastMessageContent);
|
||
Assert.Equal(bobReceived.Id, conv.LastMessageId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task SendMessage_increments_redis_unread_for_other_participant_only()
|
||
{
|
||
// Arrange:Alice、Bob、Carol 三人群聊。发消息时只给除发送者外的两人累加未读
|
||
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
|
||
var (convId, _) = await ImTestSeeder.SeedGroupConversationAsync(
|
||
_fixture, "三人测试群", ImServerFixture.AliceId,
|
||
(ImServerFixture.AliceId, GroupMemberRoleEnum.Owner),
|
||
(ImServerFixture.BobId, GroupMemberRoleEnum.Member),
|
||
(ImServerFixture.CarolId, GroupMemberRoleEnum.Member));
|
||
|
||
var alice = await ConnectAsync(ImServerFixture.AliceId, "Alice");
|
||
|
||
// Act:Alice 发一条消息
|
||
await alice.InvokeAsync("SendMessage", new SendMessageRequest(
|
||
convId, MessageContentTypeEnum.Text, "group hi", null, null));
|
||
|
||
// Assert:Redis 未读计数 Bob=1、Carol=1,发送者 Alice 无未读键
|
||
await using var scope = _fixture.Services.CreateAsyncScope();
|
||
var unread = scope.ServiceProvider.GetRequiredService<IUnreadCounter>();
|
||
var bobUnread = await unread.GetAsync(convId, ImServerFixture.BobId);
|
||
var carolUnread = await unread.GetAsync(convId, ImServerFixture.CarolId);
|
||
var aliceUnread = await unread.GetAsync(convId, ImServerFixture.AliceId);
|
||
|
||
Assert.Equal(1, bobUnread);
|
||
Assert.Equal(1, carolUnread);
|
||
// 发送者不计未读:Redis 中该字段为空,GetAsync 返回 null
|
||
Assert.Null(aliceUnread);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task SendMessage_triggers_offline_notification_for_offline_recipients()
|
||
{
|
||
// Arrange:Bob 离线(不连接)、Carol 在线(连接)。离线触达仅应发给 Bob
|
||
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
|
||
var (convId, _) = await ImTestSeeder.SeedGroupConversationAsync(
|
||
_fixture, "离线通知群", ImServerFixture.AliceId,
|
||
(ImServerFixture.AliceId, GroupMemberRoleEnum.Owner),
|
||
(ImServerFixture.BobId, GroupMemberRoleEnum.Member),
|
||
(ImServerFixture.CarolId, GroupMemberRoleEnum.Member));
|
||
|
||
var alice = await ConnectAsync(ImServerFixture.AliceId, "Alice");
|
||
_ = await ConnectAsync(ImServerFixture.CarolId, "Carol"); // Carol 在线
|
||
_fixture.NotificationPublisher.Reset();
|
||
|
||
// Act
|
||
await alice.InvokeAsync("SendMessage", new SendMessageRequest(
|
||
convId, MessageContentTypeEnum.Text, "ping offline bob", null, null));
|
||
|
||
// 通知发布为异步,等待 fake 收到一次调用
|
||
await WaitForAsync(() =>
|
||
{
|
||
lock (_fixture.NotificationPublisher.Calls) return _fixture.NotificationPublisher.Calls.Count > 0;
|
||
});
|
||
|
||
// Assert:仅有 Bob 收到离线通知,Carol 因在线被过滤
|
||
FakeImNotificationPublisher.Call call;
|
||
lock (_fixture.NotificationPublisher.Calls) call = _fixture.NotificationPublisher.Calls[0];
|
||
Assert.Equal("im-message", call.Type);
|
||
Assert.Equal(convId, call.ConversationId);
|
||
var recipient = Assert.Single(call.Recipients);
|
||
Assert.Equal(ImServerFixture.BobId, recipient);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Typing_broadcasts_OnUserTyping_to_participants()
|
||
{
|
||
// Arrange
|
||
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
|
||
_fixture.UserInfoService.SetUser(ImServerFixture.BobId, "Bob");
|
||
var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId);
|
||
|
||
var alice = await ConnectAsync(ImServerFixture.AliceId, "Alice");
|
||
var bob = await ConnectAsync(ImServerFixture.BobId, "Bob");
|
||
|
||
Guid? typingUserId = null;
|
||
bob.On<HubEvents.UserTypingEvent>("OnUserTyping", payload => typingUserId = payload.UserId);
|
||
|
||
// Act
|
||
await alice.InvokeAsync("Typing", convId);
|
||
|
||
// Assert:Bob 收到 Alice 的输入状态
|
||
await WaitForAsync(() => typingUserId is not null);
|
||
Assert.Equal(ImServerFixture.AliceId, typingUserId);
|
||
}
|
||
|
||
/// <summary>轮询等待条件成立,避免固定 sleep。超时抛异常以便定位卡死。</summary>
|
||
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("等待条件在超时内未满足");
|
||
}
|
||
|
||
/// <summary>异步条件重载:谓词返回 Task<bool>,用于轮询 Redis 等 IO 状态。</summary>
|
||
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan? timeout = null)
|
||
{
|
||
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(5));
|
||
while (DateTime.UtcNow < deadline)
|
||
{
|
||
if (await condition()) return;
|
||
await Task.Delay(25);
|
||
}
|
||
throw new TimeoutException("等待条件在超时内未满足");
|
||
}
|
||
}
|