202 lines
9.7 KiB
C#
202 lines
9.7 KiB
C#
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;
|
||
}
|
||
}
|