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.Bson;
using MongoDB.Driver;
using Moq;
using Xunit;
namespace BuildFlow.IM.Tests.Chat;
///
/// SendMessageCommandHandler 的核心断言:未读数累加已移出 PostgreSQL 事务,改走 IUnreadCounter(Redis)。
///
public class SendMessageCommandHandlerTests
{
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>(ids => ids.Order().SequenceEqual(new[] { RecipientA, RecipientB }.Order())),
It.IsAny()),
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(), It.IsAny>(), It.IsAny()),
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>(), It.IsAny()))
.ReturnsAsync((IEnumerable 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(t => t.Contains("alice")),
"hello",
ConversationId,
It.Is>(ids => ids.SequenceEqual(new[] { RecipientA })),
It.IsAny()),
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>(), It.IsAny()))
.ReturnsAsync(new HashSet());
await handler.Handle(new SendMessageCommand(
ConversationId, SenderId, "alice",
MessageContentType.Text, "hello", null, null), CancellationToken.None);
// 所有接收者在线时,不触发任何站内信
notification.Verify(
n => n.PublishAsync(It.IsAny(), It.IsAny(), It.IsAny(),
It.IsAny(), It.IsAny>(), It.IsAny()),
Times.Never);
}
///
/// 构造 Handler,MongoDbContext 用 mock 的 IMongoClient/IMongoDatabase/IMongoCollection 装配,
/// 使 InsertOneAsync 成为可调用的空操作(消息正文落库逻辑在真实 Mongo 上验证,此处聚焦未读数语义)。
///
///
/// 构造 Handler,MongoDbContext 用 mock 的 IMongoClient/IMongoDatabase/IMongoCollection 装配,
/// 使 InsertOneAsync 成为可调用的空操作(消息正文落库逻辑在真实 Mongo 上验证,此处聚焦未读数语义)。
/// 默认所有接收者视为离线(IOnlinePresenceService.FilterOfflineAsync 返回全部),
/// 以便测试通知链路;个别测试可覆盖 onlinePresence 的行为。
///
private static (SendMessageCommandHandler Handler, Mock UnreadCounter, Mock UserInfo, Mock OnlinePresence, Mock Notification) BuildHandler(ImDbContext db)
{
var unreadCounter = new Mock();
var messages = new Mock>();
// 模拟 InsertOneAsync 生成 ObjectId(真实 Mongo 在写入时分配),便于断言 LastMessageId 被设置
messages.Setup(m => m.InsertOneAsync(It.IsAny(), null, It.IsAny()))
.Callback((doc, _, _) =>
doc.Id = ObjectId.GenerateNewId().ToString());
var mongoDatabase = new Mock();
mongoDatabase.Setup(d => d.GetCollection("messages", null))
.Returns(messages.Object);
var mongoClient = new Mock();
mongoClient.Setup(c => c.GetDatabase(It.IsAny(), null))
.Returns(mongoDatabase.Object);
var mongoDb = new MongoDbContext(mongoClient.Object, "im-test");
var userInfo = new Mock();
userInfo.Setup(u => u.GetUsersAsync(It.IsAny>(), It.IsAny()))
.ReturnsAsync(new Dictionary());
// 默认全部离线(返回传入集合本身),驱动通知触发
var onlinePresence = new Mock();
onlinePresence.Setup(o => o.FilterOfflineAsync(It.IsAny>(), It.IsAny()))
.ReturnsAsync((IEnumerable ids, CancellationToken _) => ids.ToHashSet());
var notification = new Mock();
var handler = new SendMessageCommandHandler(
db, mongoDb, userInfo.Object, unreadCounter.Object, onlinePresence.Object, notification.Object);
return (handler, unreadCounter, userInfo, onlinePresence, notification);
}
private static ImDbContext BuildInMemoryDb()
{
var services = new ServiceCollection();
services.AddDbContext(o => o.UseInMemoryDatabase($"im-test-{Guid.NewGuid()}"));
var db = services.BuildServiceProvider().GetRequiredService();
return db;
}
}