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;
///
/// SendMessageCommandHandler 的核心断言:未读数累加已移出 PostgreSQL 事务,改走 IUnreadCounter(Redis)。
///
public class SendMessageCommandHandlerTests
{
/// 本地测试 MongoDB 连接串(与集成测试共用,需本地 docker 起 mongo)。
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>(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。Mongo 消息正文落真实库(静态 DB.Default 已在静态构造函数初始化),
/// 此处聚焦未读数语义。默认所有接收者视为离线(驱动通知链路),个别测试可覆盖。
///
private static (SendMessageCommandHandler Handler, Mock UnreadCounter, Mock UserInfo, Mock OnlinePresence, Mock Notification) BuildHandler(ImDbContext db)
{
var unreadCounter = new Mock();
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, 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;
}
}