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

193 lines
9.0 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.Commands;
using IM.Application.Chat.Handlers;
using IM.Domain.Common;
using IM.Domain.Enums;
using IM.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
using MongoDB.Entities;
using Moq;
using Xunit;
namespace BuildFlow.IM.Tests.Chat;
/// <summary>
/// SendMessageCommandHandler 的核心断言:未读数累加已移出 PostgreSQL 事务,改走 IUnreadCounter(Redis)。
/// </summary>
public class SendMessageCommandHandlerTests
{
/// <summary>本地测试 MongoDB 连接串(与集成测试共用,需本地 docker 起 mongo)。</summary>
private const string MongoConnection = "mongodb://mongo:mongo123@localhost:27017/im_unit_test?authSource=admin";
static SendMessageCommandHandlerTests()
{
// MongoDB.Entities 用静态 DB.Default,进程内一次性初始化(幂等)。
// 消息正文落真实 Mongo,但本测试仅断言未读数语义,不校验 Mongo 内容。
var dbName = MongoUrl.Create(MongoConnection).DatabaseName!;
DB.InitAsync(dbName, MongoClientSettings.FromConnectionString(MongoConnection)).GetAwaiter().GetResult();
}
private static readonly Guid SenderId = Guid.Parse("22222222-2222-2222-2222-222222222222");
private static readonly Guid RecipientA = Guid.Parse("30000000-0000-0000-0000-000000000001");
private static readonly Guid RecipientB = Guid.Parse("30000000-0000-0000-0000-000000000002");
private static readonly Guid ConversationId = Guid.Parse("11111111-1111-1111-1111-111111111111");
[Fact]
public async Task Does_not_touch_pg_unread_count_and_calls_redis_increment_for_others()
{
// Arrange:构造一个含发送者+两个接收者的会话(接收者带初始未读数,验证 Handler 不再原地 +1)
await using var db = BuildInMemoryDb();
db.Conversations.Add(ImTestData.BuildConversation(ConversationId, ConversationTypeEnum.Group,
(SenderId, 0), (RecipientA, 5), (RecipientB, 0)));
await db.SaveChangesAsync();
var (handler, unreadCounter, _, _, _) = BuildHandler(db);
// Act
await handler.Handle(new SendMessageCommand(
ConversationId, SenderId, "alice",
MessageContentTypeEnum.Text, "hello", null, null), CancellationToken.None);
// Assert ①:PG 中接收者的未读数保持原值(未被 Handler 修改)
var aAfter = await db.ConversationParticipants.FirstAsync(p => p.UserId == RecipientA);
var bAfter = await db.ConversationParticipants.FirstAsync(p => p.UserId == RecipientB);
Assert.Equal(5, aAfter.UnreadCount);
Assert.Equal(0, bAfter.UnreadCount);
// Assert ②:Redis 未读累加被调用一次,且参数恰好是两个接收者(排除发送者)
unreadCounter.Verify(
c => c.IncrementAsync(ConversationId,
It.Is<IEnumerable<Guid>>(ids => ids.Order().SequenceEqual(new[] { RecipientA, RecipientB }.Order())),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task Skips_redis_increment_when_no_other_participants()
{
await using var db = BuildInMemoryDb();
db.Conversations.Add(ImTestData.BuildConversation(ConversationId, ConversationTypeEnum.Private,
(SenderId, 0)));
await db.SaveChangesAsync();
var (handler, unreadCounter, _, _, _) = BuildHandler(db);
await handler.Handle(new SendMessageCommand(
ConversationId, SenderId, "alice",
MessageContentTypeEnum.Text, "solo", null, null), CancellationToken.None);
// 没有其他参与者时,不应触发 Redis 累加
unreadCounter.Verify(
c => c.IncrementAsync(It.IsAny<Guid>(), It.IsAny<IEnumerable<Guid>>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task Updates_conversation_last_message_summary()
{
await using var db = BuildInMemoryDb();
db.Conversations.Add(ImTestData.BuildConversation(ConversationId, ConversationTypeEnum.Private,
(SenderId, 0), (RecipientA, 0)));
await db.SaveChangesAsync();
var (handler, _, _, _, _) = BuildHandler(db);
await handler.Handle(new SendMessageCommand(
ConversationId, SenderId, "alice",
MessageContentTypeEnum.Image, "[图片]", null, null), CancellationToken.None);
var conv = await db.Conversations.FirstAsync(c => c.Id == ConversationId);
Assert.Equal("[图片]", conv.LastMessageContent);
Assert.NotNull(conv.LastMessageId);
Assert.NotNull(conv.LastMessageAt);
}
[Fact]
public async Task Publishes_notification_to_offline_recipients_only()
{
// Arrange:RecipientA 离线、RecipientB 在线
await using var db = BuildInMemoryDb();
db.Conversations.Add(ImTestData.BuildConversation(ConversationId, ConversationTypeEnum.Group,
(SenderId, 0), (RecipientA, 0), (RecipientB, 0)));
await db.SaveChangesAsync();
var (handler, _, _, onlinePresence, notification) = BuildHandler(db);
onlinePresence.Setup(o => o.FilterOfflineAsync(It.IsAny<IEnumerable<Guid>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((IEnumerable<Guid> ids, CancellationToken _) =>
ids.Where(id => id == RecipientA).ToHashSet());
// Act
await handler.Handle(new SendMessageCommand(
ConversationId, SenderId, "alice",
MessageContentTypeEnum.Text, "hello", null, null), CancellationToken.None);
// Assert:仅对离线的 RecipientA 发通知(Source=im),在线的 RecipientB 不在收件人列表
notification.Verify(
n => n.PublishAsync(
"im-message",
It.Is<string>(t => t.Contains("alice")),
"hello",
ConversationId,
It.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(new[] { RecipientA })),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task Skips_notification_when_all_recipients_online()
{
await using var db = BuildInMemoryDb();
db.Conversations.Add(ImTestData.BuildConversation(ConversationId, ConversationTypeEnum.Private,
(SenderId, 0), (RecipientA, 0)));
await db.SaveChangesAsync();
var (handler, _, _, onlinePresence, notification) = BuildHandler(db);
// 全部在线:FilterOfflineAsync 返回空集合
onlinePresence.Setup(o => o.FilterOfflineAsync(It.IsAny<IEnumerable<Guid>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new HashSet<Guid>());
await handler.Handle(new SendMessageCommand(
ConversationId, SenderId, "alice",
MessageContentTypeEnum.Text, "hello", null, null), CancellationToken.None);
// 所有接收者在线时,不触发任何站内信
notification.Verify(
n => n.PublishAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<Guid>(), It.IsAny<IEnumerable<Guid>>(), It.IsAny<CancellationToken>()),
Times.Never);
}
/// <summary>
/// 构造 Handler。Mongo 消息正文落真实库(静态 DB.Default 已在静态构造函数初始化),
/// 此处聚焦未读数语义。默认所有接收者视为离线(驱动通知链路),个别测试可覆盖。
/// </summary>
private static (SendMessageCommandHandler Handler, Mock<IUnreadCounter> UnreadCounter, Mock<IUserInfoService> UserInfo, Mock<IOnlinePresenceService> OnlinePresence, Mock<IImNotificationPublisher> Notification) BuildHandler(ImDbContext db)
{
var unreadCounter = new Mock<IUnreadCounter>();
var userInfo = new Mock<IUserInfoService>();
userInfo.Setup(u => u.GetUsersAsync(It.IsAny<IEnumerable<Guid>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new Dictionary<string, (string, string?)>());
// 默认全部离线(返回传入集合本身),驱动通知触发
var onlinePresence = new Mock<IOnlinePresenceService>();
onlinePresence.Setup(o => o.FilterOfflineAsync(It.IsAny<IEnumerable<Guid>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((IEnumerable<Guid> ids, CancellationToken _) => ids.ToHashSet());
var notification = new Mock<IImNotificationPublisher>();
var handler = new SendMessageCommandHandler(
db, userInfo.Object, unreadCounter.Object, onlinePresence.Object, notification.Object);
return (handler, unreadCounter, userInfo, onlinePresence, notification);
}
private static ImDbContext BuildInMemoryDb()
{
var services = new ServiceCollection();
services.AddDbContext<ImDbContext>(o => o.UseInMemoryDatabase($"im-test-{Guid.NewGuid()}"));
var db = services.BuildServiceProvider().GetRequiredService<ImDbContext>();
return db;
}
}