refactor: query platform users through mediator
This commit is contained in:
parent
ce3f29e9cd
commit
a5a4446e6c
@ -227,11 +227,11 @@ git commit -m "refactor: stream rag answers through cqrs"
|
||||
- Replace: `tests/BuildFlow.IM.Integration.Tests/InMemoryUserInfoService.cs` with `InMemoryUserSummaryQueryHandler.cs`
|
||||
- Modify: `tests/BuildFlow.IM.Integration.Tests/ImServerFixture.cs`
|
||||
|
||||
- [ ] **Step 1: Write Platform query tests and update IM tests to expect mediator sends**
|
||||
- [x] **Step 1: Write Platform query tests and update IM tests to expect mediator sends**
|
||||
|
||||
The Platform query returns a `Dictionary<Guid, UserSummaryDto>` for distinct requested IDs and performs one database query. IM tests must configure `IMediator.Send(new GetUserSummariesQuery(...), ct)` and verify that empty input avoids a send where practical.
|
||||
The Platform query returns an `IReadOnlyDictionary<Guid, UserSummaryDto>` for distinct requested IDs and performs one database query. IM tests must configure `IMediator.Send(new GetUserSummariesQuery(...), ct)` and verify that empty input avoids a send where practical.
|
||||
|
||||
- [ ] **Step 2: Run focused tests to verify RED**
|
||||
- [x] **Step 2: Run focused tests to verify RED**
|
||||
|
||||
```bash
|
||||
dotnet test tests/BuildFlow.Platform.Tests/BuildFlow.Platform.Tests.csproj --no-restore --filter FullyQualifiedName~GetUserSummaries
|
||||
@ -240,17 +240,17 @@ dotnet test tests/BuildFlow.IM.Tests/BuildFlow.IM.Tests.csproj --no-restore --fi
|
||||
|
||||
Expected: FAIL until the target-owned query and IM handler changes exist.
|
||||
|
||||
- [ ] **Step 3: Implement the direct CQRS calls**
|
||||
- [x] **Step 3: Implement the direct CQRS calls**
|
||||
|
||||
Add a Platform Application project reference to IM Application. Each IM handler injects `IMediator`, sends one `GetUserSummariesQuery` for its deduplicated IDs, and reads results by `Guid` key. Do not catch all exceptions and do not introduce a replacement service/interface bridge.
|
||||
|
||||
The integration fake implements `IRequestHandler<GetUserSummariesQuery, Dictionary<Guid, UserSummaryDto>>`; `ImServerFixture` registers both the IM Application assembly and the test assembly with MediatR.
|
||||
The integration fake implements `IRequestHandler<GetUserSummariesQuery, IReadOnlyDictionary<Guid, UserSummaryDto>>`; `ImServerFixture` registers both the IM Application assembly and the test assembly with MediatR.
|
||||
|
||||
- [ ] **Step 4: Remove bridge interface, Host implementation, and registration**
|
||||
- [x] **Step 4: Remove bridge interface, Host implementation, and registration**
|
||||
|
||||
Delete `IUserInfoService` and `InProcessUserInfoService`; remove the Host `AddScoped<IUserInfoService, InProcessUserInfoService>()` registration and stale using directives/comments.
|
||||
|
||||
- [ ] **Step 5: Run IM unit and SignalR integration tests**
|
||||
- [x] **Step 5: Run IM unit and SignalR integration tests**
|
||||
|
||||
```bash
|
||||
dotnet test tests/BuildFlow.IM.Tests/BuildFlow.IM.Tests.csproj --no-restore
|
||||
@ -259,7 +259,7 @@ dotnet test tests/BuildFlow.IM.Integration.Tests/BuildFlow.IM.Integration.Tests.
|
||||
|
||||
Expected: PASS with the same private/group conversation display names and notification behavior.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
- [x] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/modules/platform/Application/Users src/modules/im src/BuildFlow.Host tests/BuildFlow.IM.Tests tests/BuildFlow.IM.Integration.Tests
|
||||
|
||||
@ -91,7 +91,6 @@ public class BuildFlowHostModule : AbpModule
|
||||
// 当前用户上下文:各模块的 CurrentUserContext 实现
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddScoped<BuildFlow.Shared.Domain.Common.ICurrentUserContext, CurrentUserContext>();
|
||||
services.AddScoped<IUserInfoService, InProcessUserInfoService>();
|
||||
|
||||
// 串联:IM @AI → RAG 问答(跨模块集成,RAG 实现 IAiBotHandler 供 IM 调用)
|
||||
services
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
using MediatR;
|
||||
using BuildFlow.Platform.Application.Users.Queries;
|
||||
using IM.Domain.Common;
|
||||
|
||||
namespace BuildFlow.Host.Services.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// 进程内用户信息查询实现,替代 IM 原有的 gRPC 版 <c>AuthGrpcClientService</c>。
|
||||
/// 通过 MediatR 查询调用 rag 模块的 <see cref="GetUserNamesQuery"/> 处理器,
|
||||
/// 而非直接注入 <c>PlatformDbContext</c>——这样 im 模块对 rag 的依赖从「直接引用 DbContext」
|
||||
/// 降级为「发 MediatR 消息」,实现模块间解耦。im 只依赖消息契约,不感知 rag 的数据访问实现。
|
||||
///
|
||||
/// 容错策略与 gRPC 版一致:查询失败返回空字典,避免 IM 核心链路因数据库抖动而整体不可用。
|
||||
/// 注册为 <c>IUserInfoService</c> 的覆盖实现(后注册者胜出),让 BuildFlow Host 内的 IM 直接查询用户信息。
|
||||
/// </summary>
|
||||
public class InProcessUserInfoService(IMediator mediator) : IUserInfoService
|
||||
{
|
||||
public async Task<Dictionary<string, (string Username, string? Avatar)>> GetUsersAsync(
|
||||
IEnumerable<Guid> userIds, CancellationToken ct = default)
|
||||
{
|
||||
var idList = userIds.ToList();
|
||||
if (idList.Count == 0)
|
||||
return [];
|
||||
|
||||
try
|
||||
{
|
||||
// 通过 MediatR 查询调用 rag 模块的 GetUserNamesQueryHandler(它内部查 PlatformDbContext.Users)
|
||||
var names = await mediator.Send(new GetUserNamesQuery(idList), ct);
|
||||
|
||||
// rag 的 User 无 Avatar 字段(gRPC 服务端也返回 Avatar=""),故头像恒为 null
|
||||
return names.ToDictionary(
|
||||
kv => kv.Key.ToString(),
|
||||
kv => (kv.Value, (string?)null));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 数据库不可用时降级返回空,保证消息发送主链路不被阻断(与 gRPC 版容错一致)
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
using BuildFlow.Platform.Application.Users.Queries;
|
||||
using IM.Application.Chat.Commands;
|
||||
using IM.Application.Chat.DTOs;
|
||||
using IM.Domain.Common;
|
||||
using IM.Domain.Entities.Conversation;
|
||||
using IM.Domain.Entities.ConversationParticipant;
|
||||
using IM.Domain.Enums;
|
||||
@ -14,7 +14,7 @@ namespace IM.Application.Chat.Handlers;
|
||||
/// </summary>
|
||||
public class CreatePrivateConversationCommandHandler(
|
||||
ImDbContext db,
|
||||
IUserInfoService userInfoService)
|
||||
IMediator mediator)
|
||||
: IRequestHandler<CreatePrivateConversationCommand, ConversationDto>
|
||||
{
|
||||
public async Task<ConversationDto> Handle(
|
||||
@ -39,8 +39,8 @@ public class CreatePrivateConversationCommandHandler(
|
||||
})
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
var users = await userInfoService.GetUsersAsync([request.TargetUserId], ct);
|
||||
var targetUser = users.GetValueOrDefault(request.TargetUserId.ToString());
|
||||
var users = await mediator.Send(new GetUserSummariesQuery([request.TargetUserId]), ct);
|
||||
var targetUser = users.GetValueOrDefault(request.TargetUserId);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
@ -52,8 +52,8 @@ public class CreatePrivateConversationCommandHandler(
|
||||
existing.MyUnread,
|
||||
null, null, null,
|
||||
request.TargetUserId,
|
||||
targetUser.Username,
|
||||
targetUser.Avatar);
|
||||
targetUser?.Username,
|
||||
targetUser?.Avatar);
|
||||
}
|
||||
|
||||
// 创建新私聊会话
|
||||
@ -88,7 +88,7 @@ public class CreatePrivateConversationCommandHandler(
|
||||
null, null, 0,
|
||||
null, null, null,
|
||||
request.TargetUserId,
|
||||
targetUser.Username,
|
||||
targetUser.Avatar);
|
||||
targetUser?.Username,
|
||||
targetUser?.Avatar);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
using BuildFlow.Platform.Application.Users.DTOs;
|
||||
using BuildFlow.Platform.Application.Users.Queries;
|
||||
using IM.Application.Chat.DTOs;
|
||||
using IM.Application.Chat.Queries;
|
||||
using IM.Domain.Common;
|
||||
@ -11,7 +13,7 @@ namespace IM.Application.Chat.Handlers;
|
||||
/// </summary>
|
||||
public class GetConversationsQueryHandler(
|
||||
ImDbContext db,
|
||||
IUserInfoService userInfoService,
|
||||
IMediator mediator,
|
||||
IUnreadCounter unreadCounter)
|
||||
: IRequestHandler<GetConversationsQuery, List<ConversationDto>>
|
||||
{
|
||||
@ -31,7 +33,7 @@ public class GetConversationsQueryHandler(
|
||||
.ThenByDescending(p => p.ConversationEntity.LastMessageAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// 私聊会话需解析"对方"用户以展示头像/昵称;批量收集去重后一次性查 gRPC,避免逐会话往返
|
||||
// 私聊会话需解析"对方"用户以展示头像/昵称;批量收集去重后一次性查询 Platform,避免逐会话往返。
|
||||
var otherUserIds = participants
|
||||
.Where(p => p.ConversationEntity.Type == Domain.Enums.ConversationTypeEnum.Private)
|
||||
.Select(p => p.ConversationEntity.Participants
|
||||
@ -42,8 +44,8 @@ public class GetConversationsQueryHandler(
|
||||
.ToList();
|
||||
|
||||
var users = otherUserIds.Count > 0
|
||||
? await userInfoService.GetUsersAsync(otherUserIds, ct)
|
||||
: new Dictionary<string, (string Username, string? Avatar)>();
|
||||
? await mediator.Send(new GetUserSummariesQuery(otherUserIds), ct)
|
||||
: new Dictionary<Guid, UserSummaryDto>();
|
||||
|
||||
var redisUnread = await unreadCounter.GetByUserAsync(
|
||||
request.UserId,
|
||||
@ -63,9 +65,9 @@ public class GetConversationsQueryHandler(
|
||||
if (otherParticipant is not null)
|
||||
{
|
||||
otherUserId = otherParticipant.UserId;
|
||||
var userInfo = users.GetValueOrDefault(otherParticipant.UserId.ToString());
|
||||
otherUserName = userInfo.Username;
|
||||
otherUserAvatar = userInfo.Avatar;
|
||||
var userInfo = users.GetValueOrDefault(otherParticipant.UserId);
|
||||
otherUserName = userInfo?.Username;
|
||||
otherUserAvatar = userInfo?.Avatar;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
using BuildFlow.Platform.Application.Users.DTOs;
|
||||
using BuildFlow.Platform.Application.Users.Queries;
|
||||
using BuildFlow.Shared.Domain.Exceptions;
|
||||
using IM.Application.Chat.DTOs;
|
||||
using IM.Application.Chat.Queries;
|
||||
using IM.Domain.Common;
|
||||
using IM.Infrastructure.Persistence;
|
||||
using MediatR;
|
||||
|
||||
@ -10,7 +11,7 @@ namespace IM.Application.Chat.Handlers;
|
||||
/// <summary>
|
||||
/// 群成员列表查询处理器。校验请求者群成员身份后返回成员。
|
||||
/// </summary>
|
||||
public class GetGroupMembersQueryHandler(ImDbContext db, IUserInfoService userInfoService) : IRequestHandler<GetGroupMembersQuery, List<GroupMemberDto>>
|
||||
public class GetGroupMembersQueryHandler(ImDbContext db, IMediator mediator) : IRequestHandler<GetGroupMembersQuery, List<GroupMemberDto>>
|
||||
{
|
||||
public async Task<List<GroupMemberDto>> Handle(GetGroupMembersQuery request, CancellationToken ct)
|
||||
{
|
||||
@ -25,14 +26,16 @@ public class GetGroupMembersQueryHandler(ImDbContext db, IUserInfoService userIn
|
||||
.Where(m => m.GroupId == request.GroupId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// 批量反查用户名/头像,避免逐成员调用 gRPC
|
||||
var userIds = members.Select(m => m.UserId).ToList();
|
||||
var users = await userInfoService.GetUsersAsync(userIds, ct);
|
||||
// 批量反查用户名/头像,避免逐成员跨模块查询。
|
||||
var userIds = members.Select(m => m.UserId).Distinct().ToList();
|
||||
var users = userIds.Count > 0
|
||||
? await mediator.Send(new GetUserSummariesQuery(userIds), ct)
|
||||
: new Dictionary<Guid, UserSummaryDto>();
|
||||
|
||||
return members.Select(m =>
|
||||
{
|
||||
var userInfo = users.GetValueOrDefault(m.UserId.ToString());
|
||||
return new GroupMemberDto(m.UserId, userInfo.Username ?? string.Empty, userInfo.Avatar, m.Role, m.JoinedAt);
|
||||
var userInfo = users.GetValueOrDefault(m.UserId);
|
||||
return new GroupMemberDto(m.UserId, userInfo?.Username ?? string.Empty, userInfo?.Avatar, m.Role, m.JoinedAt);
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
using BuildFlow.Platform.Application.Users.DTOs;
|
||||
using BuildFlow.Platform.Application.Users.Queries;
|
||||
using BuildFlow.Shared.Domain.Exceptions;
|
||||
using IM.Application.Chat.DTOs;
|
||||
using IM.Application.Chat.Queries;
|
||||
using IM.Domain.Common;
|
||||
using IM.Infrastructure.Mongo;
|
||||
using IM.Infrastructure.Persistence;
|
||||
using MediatR;
|
||||
@ -14,7 +15,7 @@ namespace IM.Application.Chat.Handlers;
|
||||
/// </summary>
|
||||
public class GetMessageHistoryQueryHandler(
|
||||
ImDbContext db,
|
||||
IUserInfoService userInfoService) : IRequestHandler<GetMessageHistoryQuery, MessageHistoryDto>
|
||||
IMediator mediator) : IRequestHandler<GetMessageHistoryQuery, MessageHistoryDto>
|
||||
{
|
||||
public async Task<MessageHistoryDto> Handle(GetMessageHistoryQuery request, CancellationToken ct)
|
||||
{
|
||||
@ -44,7 +45,7 @@ public class GetMessageHistoryQueryHandler(
|
||||
if (hasMore)
|
||||
messages.RemoveAt(messages.Count - 1);
|
||||
|
||||
// 头像懒补全:仅对消息文档中头像字段为空的发送者批量反查 gRPC,减少调用次数
|
||||
// 头像懒补全:仅对消息文档中头像字段为空的发送者批量查询 Platform,减少调用次数。
|
||||
var senderIds = messages
|
||||
.Where(m => string.IsNullOrEmpty(m.SenderAvatar))
|
||||
.Select(m => m.SenderId)
|
||||
@ -52,15 +53,15 @@ public class GetMessageHistoryQueryHandler(
|
||||
.ToList();
|
||||
|
||||
var users = senderIds.Count > 0
|
||||
? await userInfoService.GetUsersAsync(senderIds, ct)
|
||||
: new Dictionary<string, (string Username, string? Avatar)>();
|
||||
? await mediator.Send(new GetUserSummariesQuery(senderIds), ct)
|
||||
: new Dictionary<Guid, UserSummaryDto>();
|
||||
|
||||
var dtos = messages.Select(m =>
|
||||
{
|
||||
var avatar = m.SenderAvatar;
|
||||
if (string.IsNullOrEmpty(avatar))
|
||||
{
|
||||
avatar = users.GetValueOrDefault(m.SenderId.ToString()).Avatar;
|
||||
avatar = users.GetValueOrDefault(m.SenderId)?.Avatar;
|
||||
}
|
||||
|
||||
return new MessageDto(
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
using BuildFlow.Platform.Application.Users.DTOs;
|
||||
using BuildFlow.Platform.Application.Users.Queries;
|
||||
using BuildFlow.Shared.Domain.Exceptions;
|
||||
using IM.Application.Chat.DTOs;
|
||||
using IM.Application.Chat.Queries;
|
||||
using IM.Domain.Common;
|
||||
using IM.Infrastructure.Mongo;
|
||||
using IM.Infrastructure.Persistence;
|
||||
using MediatR;
|
||||
@ -14,7 +15,7 @@ namespace IM.Application.Chat.Handlers;
|
||||
/// </summary>
|
||||
public class GetMessagesAroundQueryHandler(
|
||||
ImDbContext db,
|
||||
IUserInfoService userInfoService) : IRequestHandler<GetMessagesAroundQuery, MessageHistoryDto>
|
||||
IMediator mediator) : IRequestHandler<GetMessagesAroundQuery, MessageHistoryDto>
|
||||
{
|
||||
public async Task<MessageHistoryDto> Handle(GetMessagesAroundQuery request, CancellationToken ct)
|
||||
{
|
||||
@ -98,15 +99,15 @@ public class GetMessagesAroundQueryHandler(
|
||||
.ToList();
|
||||
|
||||
var users = senderIds.Count > 0
|
||||
? await userInfoService.GetUsersAsync(senderIds, ct)
|
||||
: new Dictionary<string, (string Username, string? Avatar)>();
|
||||
? await mediator.Send(new GetUserSummariesQuery(senderIds), ct)
|
||||
: new Dictionary<Guid, UserSummaryDto>();
|
||||
|
||||
return messages.Select(m =>
|
||||
{
|
||||
var avatar = m.SenderAvatar;
|
||||
if (string.IsNullOrEmpty(avatar))
|
||||
{
|
||||
avatar = users.GetValueOrDefault(m.SenderId.ToString()).Avatar;
|
||||
avatar = users.GetValueOrDefault(m.SenderId)?.Avatar;
|
||||
}
|
||||
|
||||
return new MessageDto(
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using BuildFlow.Platform.Application.Users.DTOs;
|
||||
using BuildFlow.Platform.Application.Users.Queries;
|
||||
using BuildFlow.Shared.Domain.Exceptions;
|
||||
using IM.Application.Chat.DTOs;
|
||||
using IM.Application.Chat.Queries;
|
||||
using IM.Domain.Common;
|
||||
using IM.Infrastructure.Mongo;
|
||||
using IM.Infrastructure.Persistence;
|
||||
using MediatR;
|
||||
@ -16,7 +17,7 @@ namespace IM.Application.Chat.Handlers;
|
||||
/// </summary>
|
||||
public class SearchMessagesQueryHandler(
|
||||
ImDbContext db,
|
||||
IUserInfoService userInfoService) : IRequestHandler<SearchMessagesQuery, MessageHistoryDto>
|
||||
IMediator mediator) : IRequestHandler<SearchMessagesQuery, MessageHistoryDto>
|
||||
{
|
||||
public async Task<MessageHistoryDto> Handle(SearchMessagesQuery request, CancellationToken ct)
|
||||
{
|
||||
@ -61,15 +62,15 @@ public class SearchMessagesQueryHandler(
|
||||
.ToList();
|
||||
|
||||
var users = senderIds.Count > 0
|
||||
? await userInfoService.GetUsersAsync(senderIds, ct)
|
||||
: new Dictionary<string, (string Username, string? Avatar)>();
|
||||
? await mediator.Send(new GetUserSummariesQuery(senderIds), ct)
|
||||
: new Dictionary<Guid, UserSummaryDto>();
|
||||
|
||||
return messages.Select(m =>
|
||||
{
|
||||
var avatar = m.SenderAvatar;
|
||||
if (string.IsNullOrEmpty(avatar))
|
||||
{
|
||||
avatar = users.GetValueOrDefault(m.SenderId.ToString()).Avatar;
|
||||
avatar = users.GetValueOrDefault(m.SenderId)?.Avatar;
|
||||
}
|
||||
|
||||
return new MessageDto(
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
using BuildFlow.Platform.Application.Users.Queries;
|
||||
using BuildFlow.Shared.Domain.Exceptions;
|
||||
using BuildFlow.Shared.Integration;
|
||||
using IM.Application.Chat.Commands;
|
||||
@ -17,7 +18,7 @@ namespace IM.Application.Chat.Handlers;
|
||||
/// </summary>
|
||||
public class SendMessageCommandHandler(
|
||||
ImDbContext db,
|
||||
IUserInfoService userInfoService,
|
||||
IMediator mediator,
|
||||
IUnreadCounter unreadCounter,
|
||||
IOnlinePresenceService onlinePresence,
|
||||
IImNotificationPublisher notificationPublisher,
|
||||
@ -40,9 +41,9 @@ public class SendMessageCommandHandler(
|
||||
if (!isParticipant)
|
||||
throw new BusinessException(ErrorCode.Business, "您不是该会话的参与者");
|
||||
|
||||
// 通过 gRPC 向 rag-backend 反查发送者头像,与消息一并冗余落库,避免历史消息渲染时再查
|
||||
var users = await userInfoService.GetUsersAsync([request.SenderId], ct);
|
||||
var senderAvatar = users.GetValueOrDefault(request.SenderId.ToString()).Avatar;
|
||||
// 批量查询 Platform 用户摘要,并把发送者头像冗余落库,避免历史消息渲染时重复查询。
|
||||
var users = await mediator.Send(new GetUserSummariesQuery([request.SenderId]), ct);
|
||||
var senderAvatar = users.GetValueOrDefault(request.SenderId)?.Avatar;
|
||||
|
||||
var message = new MessageDocument
|
||||
{
|
||||
|
||||
@ -13,5 +13,6 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Infrastructure\IM.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\platform\Application\Platform.Application.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
namespace IM.Domain.Common;
|
||||
|
||||
/// <summary>
|
||||
/// 用户信息查询抽象。供 IM 模块批量拉取用户展示名/头像,避免直接依赖外部账号服务。
|
||||
/// </summary>
|
||||
public interface IUserInfoService
|
||||
{
|
||||
Task<Dictionary<string, (string Username, string? Avatar)>> GetUsersAsync(IEnumerable<Guid> userIds, CancellationToken ct = default);
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
namespace BuildFlow.Platform.Application.Users.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// 用户摘要 DTO。供其他模块批量读取用户展示名和头像引用。
|
||||
/// </summary>
|
||||
public sealed record UserSummaryDto(
|
||||
Guid Id,
|
||||
string Username,
|
||||
string? Avatar);
|
||||
@ -0,0 +1,31 @@
|
||||
using BuildFlow.Platform.Application.Users.DTOs;
|
||||
using BuildFlow.Platform.Application.Users.Queries;
|
||||
using BuildFlow.Platform.Infrastructure.Persistence;
|
||||
using MediatR;
|
||||
|
||||
namespace BuildFlow.Platform.Application.Users.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 批量用户摘要查询处理器。单次数据库查询返回去重后的现存用户。
|
||||
/// </summary>
|
||||
public sealed class GetUserSummariesQueryHandler(PlatformDbContext db)
|
||||
: IRequestHandler<GetUserSummariesQuery, IReadOnlyDictionary<Guid, UserSummaryDto>>
|
||||
{
|
||||
public async Task<IReadOnlyDictionary<Guid, UserSummaryDto>> Handle(
|
||||
GetUserSummariesQuery request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var userIds = request.UserIds.Distinct().ToArray();
|
||||
if (userIds.Length == 0)
|
||||
{
|
||||
return new Dictionary<Guid, UserSummaryDto>();
|
||||
}
|
||||
|
||||
return await db.Users
|
||||
.Where(user => userIds.Contains(user.Id))
|
||||
.ToDictionaryAsync(
|
||||
user => user.Id,
|
||||
user => new UserSummaryDto(user.Id, user.Username, user.Avatar),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
using BuildFlow.Platform.Application.Users.DTOs;
|
||||
using MediatR;
|
||||
|
||||
namespace BuildFlow.Platform.Application.Users.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 批量用户摘要查询。按用户 ID 返回展示名和头像引用。
|
||||
/// </summary>
|
||||
public sealed record GetUserSummariesQuery(IReadOnlyCollection<Guid> UserIds)
|
||||
: IRequest<IReadOnlyDictionary<Guid, UserSummaryDto>>;
|
||||
@ -44,8 +44,8 @@ public class ChatHubAuthAndHistoryTests : IAsyncLifetime
|
||||
public async Task Non_participant_cannot_join_conversation_group()
|
||||
{
|
||||
// Arrange:Alice 与 Bob 的私聊,Outsider 不参与
|
||||
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
_fixture.UserInfoService.SetUser(ImServerFixture.BobId, "Bob");
|
||||
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
_fixture.UserSummaryHandler.SetUser(ImServerFixture.BobId, "Bob");
|
||||
var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId);
|
||||
|
||||
var outsider = await ConnectAsync(ImServerFixture.OutSiderId, "Outsider");
|
||||
@ -89,7 +89,7 @@ public class ChatHubAuthAndHistoryTests : IAsyncLifetime
|
||||
public async Task Message_history_cursor_pagination_returns_correct_page_and_hasmore()
|
||||
{
|
||||
// Arrange:直接落多条消息到 MongoDB(绕过 Hub,聚焦分页逻辑)
|
||||
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId);
|
||||
|
||||
var messageIds = await SeedMessagesAsync(convId, count: 5);
|
||||
@ -118,8 +118,8 @@ public class ChatHubAuthAndHistoryTests : IAsyncLifetime
|
||||
public async Task Search_messages_finds_text_sender_and_file_name_with_reply_metadata()
|
||||
{
|
||||
// Arrange:同一会话内准备正文、发送者、文件名三类命中;另一个会话放同关键字验证会话隔离。
|
||||
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
_fixture.UserInfoService.SetUser(ImServerFixture.BobId, "BuildFlow Bob");
|
||||
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
_fixture.UserSummaryHandler.SetUser(ImServerFixture.BobId, "BuildFlow Bob");
|
||||
var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId);
|
||||
var otherConvId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.CarolId);
|
||||
|
||||
@ -170,7 +170,7 @@ public class ChatHubAuthAndHistoryTests : IAsyncLifetime
|
||||
public async Task Get_messages_around_returns_ordered_context_window()
|
||||
{
|
||||
// Arrange:写 5 条连续历史,定位第 3 条,要求返回前后各 1 条并按时间正序。
|
||||
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId);
|
||||
var messageIds = await SeedMessagesAsync(convId, count: 5);
|
||||
|
||||
@ -193,7 +193,7 @@ public class ChatHubAuthAndHistoryTests : IAsyncLifetime
|
||||
public async Task Get_messages_around_includes_referenced_message_outside_context_window()
|
||||
{
|
||||
// Arrange:目标消息引用了更早的消息,但该引用消息不在前后 1 条上下文窗口内。
|
||||
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId);
|
||||
var baseTime = DateTime.UtcNow.AddMinutes(-4);
|
||||
|
||||
|
||||
@ -97,8 +97,8 @@ public class ChatHubLifecycleTests : IAsyncLifetime
|
||||
public async Task Connection_auto_subscribes_user_to_all_existing_conversations()
|
||||
{
|
||||
// Arrange:先播种一个会话,随后让参与者连接——验证"连接即订阅"
|
||||
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
_fixture.UserInfoService.SetUser(ImServerFixture.BobId, "Bob");
|
||||
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
_fixture.UserSummaryHandler.SetUser(ImServerFixture.BobId, "Bob");
|
||||
var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId);
|
||||
|
||||
var bob = await ConnectAsync(ImServerFixture.BobId, "Bob");
|
||||
|
||||
@ -45,8 +45,8 @@ public class ChatHubMessagingTests : IAsyncLifetime
|
||||
public async Task SendMessage_broadcasts_to_both_participants_and_persists_to_mongo()
|
||||
{
|
||||
// Arrange:私聊会话 Alice ↔ Bob,两者均在连接中
|
||||
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
_fixture.UserInfoService.SetUser(ImServerFixture.BobId, "Bob");
|
||||
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
_fixture.UserSummaryHandler.SetUser(ImServerFixture.BobId, "Bob");
|
||||
var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId);
|
||||
|
||||
var alice = await ConnectAsync(ImServerFixture.AliceId, "Alice");
|
||||
@ -94,8 +94,8 @@ public class ChatHubMessagingTests : IAsyncLifetime
|
||||
public async Task SendMessage_file_message_preserves_metadata_reply_and_updates_conversation_summary()
|
||||
{
|
||||
// Arrange:前端文件发送链路会先完成分片上传,再通过 ChatHub 发送文件名、大小和预览 URL。
|
||||
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
_fixture.UserInfoService.SetUser(ImServerFixture.BobId, "Bob");
|
||||
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
_fixture.UserSummaryHandler.SetUser(ImServerFixture.BobId, "Bob");
|
||||
var convId = await ImTestSeeder.SeedPrivateConversationAsync(
|
||||
_fixture,
|
||||
ImServerFixture.AliceId,
|
||||
@ -167,7 +167,7 @@ public class ChatHubMessagingTests : IAsyncLifetime
|
||||
public async Task SendMessage_increments_redis_unread_for_other_participant_only()
|
||||
{
|
||||
// Arrange:Alice、Bob、Carol 三人群聊。发消息时只给除发送者外的两人累加未读
|
||||
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
var (convId, _) = await ImTestSeeder.SeedGroupConversationAsync(
|
||||
_fixture, "三人测试群", ImServerFixture.AliceId,
|
||||
(ImServerFixture.AliceId, GroupMemberRoleEnum.Owner),
|
||||
@ -197,7 +197,7 @@ public class ChatHubMessagingTests : IAsyncLifetime
|
||||
public async Task SendMessage_triggers_offline_notification_for_offline_recipients()
|
||||
{
|
||||
// Arrange:Bob 离线(不连接)、Carol 在线(连接)。离线触达仅应发给 Bob
|
||||
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
var (convId, _) = await ImTestSeeder.SeedGroupConversationAsync(
|
||||
_fixture, "离线通知群", ImServerFixture.AliceId,
|
||||
(ImServerFixture.AliceId, GroupMemberRoleEnum.Owner),
|
||||
@ -231,8 +231,8 @@ public class ChatHubMessagingTests : IAsyncLifetime
|
||||
public async Task Typing_broadcasts_OnUserTyping_to_participants()
|
||||
{
|
||||
// Arrange
|
||||
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
_fixture.UserInfoService.SetUser(ImServerFixture.BobId, "Bob");
|
||||
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
_fixture.UserSummaryHandler.SetUser(ImServerFixture.BobId, "Bob");
|
||||
var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId);
|
||||
|
||||
var alice = await ConnectAsync(ImServerFixture.AliceId, "Alice");
|
||||
|
||||
@ -45,8 +45,8 @@ public class ChatHubReadAndRecallTests : IAsyncLifetime
|
||||
public async Task MarkAsRead_resets_unread_in_both_pg_and_redis_and_broadcasts()
|
||||
{
|
||||
// Arrange:私聊会话,Alice 先发消息使 Bob 产生未读
|
||||
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
_fixture.UserInfoService.SetUser(ImServerFixture.BobId, "Bob");
|
||||
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
_fixture.UserSummaryHandler.SetUser(ImServerFixture.BobId, "Bob");
|
||||
var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId);
|
||||
|
||||
var alice = await ConnectAsync(ImServerFixture.AliceId, "Alice");
|
||||
@ -94,7 +94,7 @@ public class ChatHubReadAndRecallTests : IAsyncLifetime
|
||||
public async Task RecallMessage_marks_mongo_recalled_and_updates_conversation_summary()
|
||||
{
|
||||
// Arrange:私聊会话,Alice 发一条消息(成为最后一条),随后本人撤回
|
||||
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
_fixture.UserSummaryHandler.SetUser(ImServerFixture.AliceId, "Alice");
|
||||
var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId);
|
||||
|
||||
var alice = await ConnectAsync(ImServerFixture.AliceId, "Alice");
|
||||
|
||||
@ -11,8 +11,11 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.IdentityModel.JsonWebTokens;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
using BuildFlow.Platform.Application.Users.DTOs;
|
||||
using BuildFlow.Platform.Application.Users.Queries;
|
||||
using BuildFlow.Shared.Domain.Common;
|
||||
using BuildFlow.Shared.Infrastructure;
|
||||
using IM.Application.Chat.Commands;
|
||||
@ -25,7 +28,7 @@ namespace BuildFlow.IM.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 集成测试夹具:在 TestServer 上装配真实 SignalR ChatHub + 真实 PostgreSQL / MongoDB / Redis,
|
||||
/// 仅替换两个外部 gRPC 依赖(用户信息、通知发布)为进程内 fake,使测试聚焦于"SignalR + DB"主链路。
|
||||
/// 仅替换用户摘要查询和通知发布为进程内 fake,使测试聚焦于"SignalR + DB"主链路。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 设计取舍:
|
||||
@ -70,8 +73,8 @@ public sealed class ImServerFixture : IAsyncLifetime
|
||||
/// <summary>外部通知发布的 fake:记录最后一次调用参数,便于断言离线触达。</summary>
|
||||
public FakeImNotificationPublisher NotificationPublisher { get; } = new();
|
||||
|
||||
/// <summary>外部用户信息查询的 fake:返回预设的用户名/头像。</summary>
|
||||
public InMemoryUserInfoService UserInfoService { get; } = new();
|
||||
/// <summary>Platform 用户摘要查询 fake:返回预设的用户名和头像。</summary>
|
||||
public InMemoryUserSummaryQueryHandler UserSummaryHandler { get; } = new();
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
@ -104,13 +107,17 @@ public sealed class ImServerFixture : IAsyncLifetime
|
||||
services.AddSingleton<IOnlinePresenceService, RedisOnlinePresenceService>();
|
||||
services.AddSingleton<IUnreadCounter, RedisUnreadCounter>();
|
||||
|
||||
// —— 外部 gRPC 依赖替换为进程内 fake ——
|
||||
services.AddSingleton<IUserInfoService>(UserInfoService);
|
||||
// —— 外部通知依赖替换为进程内 fake ——
|
||||
services.AddSingleton<IImNotificationPublisher>(NotificationPublisher);
|
||||
|
||||
// —— MediatR + 校验管线(与生产 Application 模块一致) ——
|
||||
var assembly = typeof(SendMessageCommand).Assembly;
|
||||
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
|
||||
services.AddMediatR(cfg => cfg
|
||||
.RegisterServicesFromAssembly(assembly)
|
||||
.RegisterServicesFromAssembly(typeof(InMemoryUserSummaryQueryHandler).Assembly));
|
||||
services.RemoveAll<IRequestHandler<GetUserSummariesQuery, IReadOnlyDictionary<Guid, UserSummaryDto>>>();
|
||||
services.AddSingleton<IRequestHandler<GetUserSummariesQuery, IReadOnlyDictionary<Guid, UserSummaryDto>>>(
|
||||
UserSummaryHandler);
|
||||
services.AddValidatorsFromAssembly(typeof(SendMessageCommand).Assembly);
|
||||
|
||||
services.AddSignalR(options =>
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
using IM.Domain.Common;
|
||||
|
||||
namespace BuildFlow.IM.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 进程内 <see cref="IUserInfoService"/> 替身:避免真实 gRPC 调用到未运行的 SSO 服务。
|
||||
/// 测试前调用 <see cref="SetUser"/> 预置用户展示名/头像,Handler 调用时按 ID 返回。
|
||||
/// </summary>
|
||||
public sealed class InMemoryUserInfoService : IUserInfoService
|
||||
{
|
||||
private readonly Dictionary<Guid, (string Username, string? Avatar)> _users = new();
|
||||
|
||||
public void SetUser(Guid userId, string username, string? avatar = null) =>
|
||||
_users[userId] = (username, avatar);
|
||||
|
||||
public Task<Dictionary<string, (string Username, string? Avatar)>> GetUsersAsync(
|
||||
IEnumerable<Guid> userIds, CancellationToken ct = default)
|
||||
{
|
||||
var result = new Dictionary<string, (string Username, string? Avatar)>();
|
||||
foreach (var id in userIds)
|
||||
{
|
||||
if (_users.TryGetValue(id, out var info))
|
||||
result[id.ToString()] = info;
|
||||
}
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
using BuildFlow.Platform.Application.Users.DTOs;
|
||||
using BuildFlow.Platform.Application.Users.Queries;
|
||||
using MediatR;
|
||||
|
||||
namespace BuildFlow.IM.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 进程内用户摘要查询处理器。测试前可预置用户展示名和头像,由 MediatR 按用户 ID 批量返回。
|
||||
/// </summary>
|
||||
public sealed class InMemoryUserSummaryQueryHandler
|
||||
: IRequestHandler<GetUserSummariesQuery, IReadOnlyDictionary<Guid, UserSummaryDto>>
|
||||
{
|
||||
private readonly Dictionary<Guid, UserSummaryDto> _users = new();
|
||||
|
||||
/// <summary>
|
||||
/// 预置一个用户摘要。
|
||||
/// </summary>
|
||||
public void SetUser(Guid userId, string username, string? avatar = null) =>
|
||||
_users[userId] = new UserSummaryDto(userId, username, avatar);
|
||||
|
||||
public Task<IReadOnlyDictionary<Guid, UserSummaryDto>> Handle(
|
||||
GetUserSummariesQuery request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
IReadOnlyDictionary<Guid, UserSummaryDto> result = request.UserIds
|
||||
.Distinct()
|
||||
.Where(_users.ContainsKey)
|
||||
.ToDictionary(userId => userId, userId => _users[userId]);
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,11 @@
|
||||
using BuildFlow.Platform.Application.Users.DTOs;
|
||||
using BuildFlow.Platform.Application.Users.Queries;
|
||||
using IM.Application.Chat.Handlers;
|
||||
using IM.Application.Chat.Queries;
|
||||
using IM.Domain.Common;
|
||||
using IM.Domain.Enums;
|
||||
using IM.Infrastructure.Persistence;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
@ -30,14 +33,14 @@ public class GetConversationsQueryHandlerTests
|
||||
(OtherUserId, 0)));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var userInfoService = new Mock<IUserInfoService>();
|
||||
userInfoService
|
||||
.Setup(s => s.GetUsersAsync(
|
||||
It.Is<IEnumerable<Guid>>(ids => ids.SequenceEqual(new[] { OtherUserId })),
|
||||
var mediator = new Mock<IMediator>();
|
||||
mediator
|
||||
.Setup(m => m.Send(
|
||||
It.Is<GetUserSummariesQuery>(q => q.UserIds.SequenceEqual(new[] { OtherUserId })),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Dictionary<string, (string Username, string? Avatar)>
|
||||
.ReturnsAsync(new Dictionary<Guid, UserSummaryDto>
|
||||
{
|
||||
[OtherUserId.ToString()] = ("Bob", null)
|
||||
[OtherUserId] = new(OtherUserId, "Bob", null)
|
||||
});
|
||||
|
||||
var unreadCounter = new Mock<IUnreadCounter>();
|
||||
@ -48,12 +51,16 @@ public class GetConversationsQueryHandlerTests
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Dictionary<Guid, int> { [ConversationId] = 2 });
|
||||
|
||||
var handler = new GetConversationsQueryHandler(db, userInfoService.Object, unreadCounter.Object);
|
||||
var handler = new GetConversationsQueryHandler(db, mediator.Object, unreadCounter.Object);
|
||||
|
||||
var result = await handler.Handle(new GetConversationsQuery(UserId), CancellationToken.None);
|
||||
|
||||
var conversation = Assert.Single(result);
|
||||
Assert.Equal(2, conversation.UnreadCount);
|
||||
Assert.Equal("Bob", conversation.OtherUserName);
|
||||
mediator.Verify(m => m.Send(
|
||||
It.Is<GetUserSummariesQuery>(q => q.UserIds.SequenceEqual(new[] { OtherUserId })),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
private static ImDbContext BuildInMemoryDb()
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
using BuildFlow.Platform.Application.Users.DTOs;
|
||||
using BuildFlow.Platform.Application.Users.Queries;
|
||||
using IM.Application.Chat.Commands;
|
||||
using IM.Application.Chat.Handlers;
|
||||
using IM.Domain.Common;
|
||||
using IM.Domain.Enums;
|
||||
using IM.Infrastructure.Persistence;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MongoDB.Driver;
|
||||
@ -103,6 +106,34 @@ public class SendMessageCommandHandlerTests
|
||||
Assert.NotNull(conv.LastMessageAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Queries_sender_summary_once_and_uses_returned_avatar()
|
||||
{
|
||||
await using var db = BuildInMemoryDb();
|
||||
db.Conversations.Add(ImTestData.BuildConversation(ConversationId, ConversationTypeEnum.Private,
|
||||
(SenderId, 0), (RecipientA, 0)));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var (handler, _, mediator, _, _) = BuildHandler(db);
|
||||
mediator
|
||||
.Setup(m => m.Send(
|
||||
It.Is<GetUserSummariesQuery>(q => q.UserIds.SequenceEqual(new[] { SenderId })),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Dictionary<Guid, UserSummaryDto>
|
||||
{
|
||||
[SenderId] = new(SenderId, "alice", "avatars/alice.png")
|
||||
});
|
||||
|
||||
var result = await handler.Handle(new SendMessageCommand(
|
||||
ConversationId, SenderId, "alice",
|
||||
MessageContentTypeEnum.Text, "hello", null, null), CancellationToken.None);
|
||||
|
||||
Assert.Equal("avatars/alice.png", result.SenderAvatar);
|
||||
mediator.Verify(m => m.Send(
|
||||
It.Is<GetUserSummariesQuery>(q => q.UserIds.SequenceEqual(new[] { SenderId })),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Publishes_notification_to_offline_recipients_only()
|
||||
{
|
||||
@ -162,13 +193,13 @@ public class SendMessageCommandHandlerTests
|
||||
/// 构造 Handler。Mongo 消息正文落真实库(静态 DB.Default 已在静态构造函数初始化),
|
||||
/// 此处聚焦未读数语义。默认所有接收者视为离线(驱动通知链路),个别测试可覆盖。
|
||||
/// </summary>
|
||||
private static (SendMessageCommandHandler Handler, Mock<IUnreadCounter> UnreadCounter, Mock<IUserInfoService> UserInfo, Mock<IOnlinePresenceService> OnlinePresence, Mock<IImNotificationPublisher> Notification) BuildHandler(ImDbContext db)
|
||||
private static (SendMessageCommandHandler Handler, Mock<IUnreadCounter> UnreadCounter, Mock<IMediator> Mediator, 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 mediator = new Mock<IMediator>();
|
||||
mediator.Setup(m => m.Send(It.IsAny<GetUserSummariesQuery>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Dictionary<Guid, UserSummaryDto>());
|
||||
|
||||
// 默认全部离线(返回传入集合本身),驱动通知触发
|
||||
var onlinePresence = new Mock<IOnlinePresenceService>();
|
||||
@ -178,8 +209,8 @@ public class SendMessageCommandHandlerTests
|
||||
var notification = new Mock<IImNotificationPublisher>();
|
||||
|
||||
var handler = new SendMessageCommandHandler(
|
||||
db, userInfo.Object, unreadCounter.Object, onlinePresence.Object, notification.Object);
|
||||
return (handler, unreadCounter, userInfo, onlinePresence, notification);
|
||||
db, mediator.Object, unreadCounter.Object, onlinePresence.Object, notification.Object);
|
||||
return (handler, unreadCounter, mediator, onlinePresence, notification);
|
||||
}
|
||||
|
||||
private static ImDbContext BuildInMemoryDb()
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
using BuildFlow.Platform.Application.Users.DTOs;
|
||||
using BuildFlow.Platform.Application.Users.Handlers;
|
||||
using BuildFlow.Platform.Application.Users.Queries;
|
||||
using Xunit;
|
||||
|
||||
namespace BuildFlow.Platform.Tests.Users;
|
||||
|
||||
/// <summary>
|
||||
/// 批量用户摘要查询测试。覆盖去重、缺失用户过滤、头像返回和空输入短路。
|
||||
/// </summary>
|
||||
public class GetUserSummariesQueryHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Handle_ShouldReturnDistinctExistingUsersByGuid()
|
||||
{
|
||||
await using var db = PlatformTestDb.CreateDbContext();
|
||||
var aliceId = Guid.NewGuid();
|
||||
var bobId = Guid.NewGuid();
|
||||
var missingId = Guid.NewGuid();
|
||||
var alice = await PlatformTestDb.SeedUserAsync(db, aliceId, "alice");
|
||||
var bob = await PlatformTestDb.SeedUserAsync(db, bobId, "bob");
|
||||
alice.Avatar = "avatars/alice.png";
|
||||
bob.Avatar = null;
|
||||
await db.SaveChangesAsync();
|
||||
var handler = new GetUserSummariesQueryHandler(db);
|
||||
|
||||
var result = await handler.Handle(
|
||||
new GetUserSummariesQuery([aliceId, bobId, aliceId, missingId]),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal(new UserSummaryDto(aliceId, "alice", "avatars/alice.png"), result[aliceId]);
|
||||
Assert.Equal(new UserSummaryDto(bobId, "bob", null), result[bobId]);
|
||||
Assert.False(result.ContainsKey(missingId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_EmptyIds_ShouldReturnEmptyResult()
|
||||
{
|
||||
await using var db = PlatformTestDb.CreateDbContext();
|
||||
var handler = new GetUserSummariesQueryHandler(db);
|
||||
|
||||
var result = await handler.Handle(new GetUserSummariesQuery([]), CancellationToken.None);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user