- 将通用 Entities 命名空间拆分为具体业务实体子目录 - 更新所有实体类型引用为具体的 Entity 后缀类型 - 将 ModelType、ModelProvider、GroupMemberRole 等枚举重命名为 Enum 后缀 - 重构 AiModel 相关配置和映射逻辑适配新的实体结构 - 更新测试代码中的实体类型引用和枚举使用 - 调整应用层处理器中的实体创建和查询逻辑 - 修改 appsettings.json 中的基础设施主机配置为动态占位符模式
193 lines
9.0 KiB
C#
193 lines
9.0 KiB
C#
using IM.Application.Chat.Commands;
|
||
using IM.Application.Chat.Handlers;
|
||
using IM.Domain.Common;
|
||
using IM.Domain.Enums;
|
||
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, ConversationTypeEnum.Group,
|
||
(SenderId, 0), (RecipientA, 5), (RecipientB, 0)));
|
||
await db.SaveChangesAsync();
|
||
|
||
var (handler, unreadCounter, _, _, _) = BuildHandler(db);
|
||
|
||
// Act
|
||
await handler.Handle(new SendMessageCommand(
|
||
ConversationId, SenderId, "alice",
|
||
MessageContentTypeEnum.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, ConversationTypeEnum.Private,
|
||
(SenderId, 0)));
|
||
await db.SaveChangesAsync();
|
||
|
||
var (handler, unreadCounter, _, _, _) = BuildHandler(db);
|
||
|
||
await handler.Handle(new SendMessageCommand(
|
||
ConversationId, SenderId, "alice",
|
||
MessageContentTypeEnum.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, ConversationTypeEnum.Private,
|
||
(SenderId, 0), (RecipientA, 0)));
|
||
await db.SaveChangesAsync();
|
||
|
||
var (handler, _, _, _, _) = BuildHandler(db);
|
||
|
||
await handler.Handle(new SendMessageCommand(
|
||
ConversationId, SenderId, "alice",
|
||
MessageContentTypeEnum.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, ConversationTypeEnum.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",
|
||
MessageContentTypeEnum.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, ConversationTypeEnum.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",
|
||
MessageContentTypeEnum.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;
|
||
}
|
||
}
|