build-flow.api/tests/BuildFlow.IM.Tests/Chat/SendMessageCommandHandlerTests.cs
xiangning 8e3878b9ff ```
test(chat): 更新消息发送命令处理器测试配置

- 移除 MongoDB 集合模拟,改为使用真实数据库连接
- 添加本地 MongoDB 测试连接字符串常量
- 在静态构造函数中初始化 MongoDB.Entities 静态连接
- 简化构建处理器方法,移除不必要的 Mock 对象
- 更新注释说明测试策略从模拟转向真实数据库
```
2026-07-03 11:50:15 +08:00

194 lines
8.9 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.Mongo;
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, ConversationType.Group,
(SenderId, 0), (RecipientA, 5), (RecipientB, 0)));
await db.SaveChangesAsync();
var (handler, unreadCounter, _, _, _) = BuildHandler(db);
// Act
await handler.Handle(new SendMessageCommand(
ConversationId, SenderId, "alice",
MessageContentType.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, ConversationType.Private,
(SenderId, 0)));
await db.SaveChangesAsync();
var (handler, unreadCounter, _, _, _) = BuildHandler(db);
await handler.Handle(new SendMessageCommand(
ConversationId, SenderId, "alice",
MessageContentType.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, ConversationType.Private,
(SenderId, 0), (RecipientA, 0)));
await db.SaveChangesAsync();
var (handler, _, _, _, _) = BuildHandler(db);
await handler.Handle(new SendMessageCommand(
ConversationId, SenderId, "alice",
MessageContentType.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, ConversationType.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",
MessageContentType.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, ConversationType.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",
MessageContentType.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;
}
}