using IM.Application.Chat.DTOs;
using IM.Domain.Common;
using IM.Domain.Enums;
using IM.Infrastructure.Mongo;
using IM.Infrastructure.Persistence;
using MongoDB.Entities;
using Xunit;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace BuildFlow.IM.Integration.Tests;
///
/// 已读回执与消息撤回的端到端用例。
/// 覆盖:已读双写清零(PG+Redis)并广播、撤回标记 Mongo 并广播、撤回会话摘要更新。
///
[Xunit.Collection(ImServerCollection.Name)]
public class ChatHubReadAndRecallTests : IAsyncLifetime
{
private readonly ImServerFixture _fixture;
private readonly List _connections = new();
public ChatHubReadAndRecallTests(ImServerFixture fixture) => _fixture = fixture;
public Task InitializeAsync() => _fixture.CleanRedisAsync();
public async Task DisposeAsync()
{
foreach (var conn in _connections)
{
try { await conn.DisposeAsync(); } catch { }
}
}
private async Task ConnectAsync(Guid userId, string userName)
{
var conn = HubConnectionFactory.Create(_fixture, userId, userName);
await conn.StartAsync();
_connections.Add(conn);
return conn;
}
[Fact]
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");
var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId);
var alice = await ConnectAsync(ImServerFixture.AliceId, "Alice");
var bob = await ConnectAsync(ImServerFixture.BobId, "Bob");
string? lastMessageId = null;
bob.On("OnMessageReceived", m => lastMessageId = m.Id);
await alice.InvokeAsync("SendMessage", new SendMessageRequest(
convId, MessageContentType.Text, "read me", null, null));
await WaitForAsync(() => lastMessageId is not null);
// 此时 Bob 有 1 条未读(Redis)
var unreadCounter = _fixture.Services.GetRequiredService();
Assert.Equal(1, await unreadCounter.GetAsync(convId, ImServerFixture.BobId));
// 监听已读广播(Alice 侧应收到 Bob 已读该消息的事件)
// 用强类型载荷解析,避免 dynamic 取属性受 JSON 命名/类型差异影响
string? readMessageId = null;
Guid? readUserId = null;
alice.On("OnMessageRead", payload =>
{
readMessageId = payload.MessageId;
readUserId = payload.UserId;
});
// Act:Bob 标记已读
await bob.InvokeAsync("MarkAsRead", new MarkAsReadRequest(convId, lastMessageId!));
// Assert ①:Redis 未读清零
await WaitForAsync(async () => await unreadCounter.GetAsync(convId, ImServerFixture.BobId) == 0);
Assert.Equal(0, await unreadCounter.GetAsync(convId, ImServerFixture.BobId));
// Assert ②:PG 未读清零、读位点更新
var pgUnread = await ImTestSeeder.GetPgUnreadAsync(_fixture, convId, ImServerFixture.BobId);
Assert.Equal(0, pgUnread);
// Assert ③:已读事件广播给会话参与者,携带正确的消息 ID 与用户
await WaitForAsync(() => readMessageId == lastMessageId);
Assert.Equal(lastMessageId, readMessageId);
Assert.Equal(ImServerFixture.BobId, readUserId);
}
[Fact]
public async Task RecallMessage_marks_mongo_recalled_and_updates_conversation_summary()
{
// Arrange:私聊会话,Alice 发一条消息(成为最后一条),随后本人撤回
_fixture.UserInfoService.SetUser(ImServerFixture.AliceId, "Alice");
var convId = await ImTestSeeder.SeedPrivateConversationAsync(_fixture, ImServerFixture.AliceId, ImServerFixture.BobId);
var alice = await ConnectAsync(ImServerFixture.AliceId, "Alice");
string? messageId = null;
alice.On("OnMessageReceived", m => messageId = m.Id);
await alice.InvokeAsync("SendMessage", new SendMessageRequest(
convId, MessageContentType.Text, "oops, recall me", null, null));
await WaitForAsync(() => messageId is not null);
// 监听撤回广播
string? recalledId = null;
alice.On("OnMessageRecalled", payload => recalledId = payload.MessageId);
// Act:Alice 撤回自己的消息(在 2 分钟时间窗内)
await alice.InvokeAsync("RecallMessage", new RecallMessageRequest(convId, messageId!));
// Assert ①:Mongo 标记撤回(MongoDB.Entities 静态 DB.Default)
var docs = await DB.Default.Find()
.Match(m => m.ID == messageId!)
.ExecuteAsync();
var doc = docs.First();
Assert.True(doc.IsRecalled);
Assert.Equal(ImServerFixture.AliceId, doc.RecalledBy);
Assert.NotNull(doc.RecalledAt);
// Assert ②:会话列表摘要更新为撤回提示(因为撤回的恰是最后一条消息)
await using (var scope2 = _fixture.Services.CreateAsyncScope())
{
var db = scope2.ServiceProvider.GetRequiredService();
var conv = await db.Conversations.AsNoTracking().FirstAsync(c => c.Id == convId);
Assert.Equal("[消息已撤回]", conv.LastMessageContent);
}
// Assert ③:撤回事件广播
await WaitForAsync(() => recalledId == messageId);
Assert.Equal(messageId, recalledId);
}
private static async Task WaitForAsync(Func condition, TimeSpan? timeout = null)
{
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(5));
while (DateTime.UtcNow < deadline)
{
if (condition()) return;
await Task.Delay(25);
}
throw new TimeoutException("等待条件在超时内未满足");
}
/// 异步条件重载:谓词返回 Task<bool>,用于轮询 Redis 等 IO 状态。
private static async Task WaitForAsync(Func> condition, TimeSpan? timeout = null)
{
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(5));
while (DateTime.UtcNow < deadline)
{
if (await condition()) return;
await Task.Delay(25);
}
throw new TimeoutException("等待条件在超时内未满足");
}
}