build-flow.api/tests/BuildFlow.IM.Tests/Chat/SendMessageCommandHandlerTests.cs

202 lines
9.7 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.Bson;
using MongoDB.Driver;
using Moq;
using Xunit;
namespace BuildFlow.IM.Tests.Chat;
/// <summary>
/// SendMessageCommandHandler 的核心断言:未读数累加已移出 PostgreSQL 事务,改走 IUnreadCounter(Redis)。
/// </summary>
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<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,MongoDbContext 用 mock 的 IMongoClient/IMongoDatabase/IMongoCollection 装配,
/// 使 InsertOneAsync 成为可调用的空操作(消息正文落库逻辑在真实 Mongo 上验证,此处聚焦未读数语义)。
/// </summary>
/// <summary>
/// 构造 Handler,MongoDbContext 用 mock 的 IMongoClient/IMongoDatabase/IMongoCollection 装配,
/// 使 InsertOneAsync 成为可调用的空操作(消息正文落库逻辑在真实 Mongo 上验证,此处聚焦未读数语义)。
/// 默认所有接收者视为离线(IOnlinePresenceService.FilterOfflineAsync 返回全部),
/// 以便测试通知链路;个别测试可覆盖 onlinePresence 的行为。
/// </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 messages = new Mock<IMongoCollection<MessageDocument>>();
// 模拟 InsertOneAsync 生成 ObjectId(真实 Mongo 在写入时分配),便于断言 LastMessageId 被设置
messages.Setup(m => m.InsertOneAsync(It.IsAny<MessageDocument>(), null, It.IsAny<CancellationToken>()))
.Callback<MessageDocument, InsertOneOptions, CancellationToken>((doc, _, _) =>
doc.Id = ObjectId.GenerateNewId().ToString());
var mongoDatabase = new Mock<IMongoDatabase>();
mongoDatabase.Setup(d => d.GetCollection<MessageDocument>("messages", null))
.Returns(messages.Object);
var mongoClient = new Mock<IMongoClient>();
mongoClient.Setup(c => c.GetDatabase(It.IsAny<string>(), null))
.Returns(mongoDatabase.Object);
var mongoDb = new MongoDbContext(mongoClient.Object, "im-test");
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, 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<ImDbContext>(o => o.UseInMemoryDatabase($"im-test-{Guid.NewGuid()}"));
var db = services.BuildServiceProvider().GetRequiredService<ImDbContext>();
return db;
}
}