using BuildFlow.Host.Hubs;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Xunit;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using FluentValidation;
using MongoDB.Driver;
using MongoDB.Entities;
using BuildFlow.Shared.Domain.Common;
using BuildFlow.Shared.Infrastructure;
using IM.Application.Chat.Commands;
using IM.Domain.Common;
using IM.Infrastructure.Cache;
using IM.Infrastructure.Persistence;
using StackExchange.Redis;
namespace BuildFlow.IM.Integration.Tests;
///
/// 集成测试夹具:在 TestServer 上装配真实 SignalR ChatHub + 真实 PostgreSQL / MongoDB / Redis,
/// 仅替换两个外部 gRPC 依赖(用户信息、通知发布)为进程内 fake,使测试聚焦于"SignalR + DB"主链路。
///
///
/// 设计取舍:
///
/// - 不复用 Program.cs 的 ABP 全量引导(Swagger/OTel/Seed 会对测试引入噪声与不确定性),
/// 而是手工注册 IM 模块所需服务,确保用例快、稳、可重复。
/// - 连接串指向独立的测试库(im_system_test / im_system_test),避免污染开发库的种子数据。
/// - Redis 共用同一实例,但用例执行前按前缀清理 im:online:* / im:unread:*,保证未读计数与在线状态隔离。
///
///
public sealed class ImServerFixture : IAsyncLifetime
{
/// 本地基础设施连接串。指向独立测试库,与开发库(im_system)隔离。
private const string PostgresConnection = "Host=localhost;Port=5432;Database=im_system_test;Username=rag;Password=REDACTED_DEV_PASSWORD";
private const string MongoConnection = "mongodb://mongo:mongo123@localhost:27017/im_system_test?authSource=admin";
private const string RedisConnection = "localhost:6379,abortConnect=false";
/// JWT 对称密钥,与 appsettings.json 中的 Jwt:SigningKey 保持一致,使 Hub 验签通过。
private const string SigningKey = "REDACTED_JWT_SIGNING_KEY_MIN_32_CHARS!!";
/// 测试用固定用户:发送者 / 接收者 / 群管理员 / 局外人。
public static readonly Guid AliceId = Guid.Parse("22222222-2222-2222-2222-222222222222");
public static readonly Guid BobId = Guid.Parse("30000000-0000-0000-0000-000000000001");
public static readonly Guid CarolId = Guid.Parse("30000000-0000-0000-0000-000000000002");
public static readonly Guid OutSiderId = Guid.Parse("99999999-9999-9999-9999-999999999999");
private WebApplication? _app;
private IConnectionMultiplexer? _redis;
/// TestServer 实例:SignalR 客户端据其 BaseAddress 与 handler 建立内存通道连接。
public TestServer TestServer { get; private set; } = null!;
/// TestServer 的基础地址,SignalR 客户端据此拼装 hub URL。
public Uri BaseAddress => TestServer.BaseAddress;
/// 应用根服务容器,供播种/清理助手解析 DbContext 等。
public IServiceProvider Services => _app!.Services;
/// 创建可复用的 HttpClient(携带 TestServer 的 handler)。
public HttpMessageHandler CreateMessageHandler() => TestServer.CreateHandler();
/// 外部通知发布的 fake:记录最后一次调用参数,便于断言离线触达。
public FakeImNotificationPublisher NotificationPublisher { get; } = new();
/// 外部用户信息查询的 fake:返回预设的用户名/头像。
public InMemoryUserInfoService UserInfoService { get; } = new();
public async Task InitializeAsync()
{
_redis = await ConnectionMultiplexer.ConnectAsync(RedisConnection);
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
builder.Configuration.AddInMemoryCollection(new Dictionary
{
["ConnectionStrings:Default"] = PostgresConnection,
["ConnectionStrings:MongoDb"] = MongoConnection,
});
var services = builder.Services;
// —— PostgreSQL:会话元数据,挂真实 AuditInterceptor(用 fake 当前用户) ——
services.TryAddSingleton();
services.AddScoped();
services.AddDbContext((sp, options) =>
options.UseNpgsql(PostgresConnection)
.AddInterceptors(sp.GetRequiredService()));
// —— MongoDB:消息正文(MongoDB.Entities 静态 DB.Default)——
var mongoDbName = MongoUrl.Create(MongoConnection).DatabaseName;
DB.InitAsync(mongoDbName, MongoClientSettings.FromConnectionString(MongoConnection)).GetAwaiter().GetResult();
// —— Redis:在线状态与未读计数的真实实现 ——
services.AddSingleton(_ => _redis!);
services.AddSingleton();
services.AddSingleton();
// —— 外部 gRPC 依赖替换为进程内 fake ——
services.AddSingleton(UserInfoService);
services.AddSingleton(NotificationPublisher);
// —— MediatR + 校验管线(与生产 Application 模块一致) ——
var assembly = typeof(SendMessageCommand).Assembly;
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
services.AddValidatorsFromAssembly(typeof(SendMessageCommand).Assembly);
services.AddSignalR(options =>
{
options.AddFilter();
// TestServer 内存传输对断开检测较慢;缩短 keep-alive 间隔,使 OnDisconnectedAsync 更快被触发
options.KeepAliveInterval = TimeSpan.FromSeconds(1);
options.ClientTimeoutInterval = TimeSpan.FromSeconds(3);
})
.AddJsonProtocol(o =>
{
// 测试客户端用强类型载荷解析,开启忽略大小写以兼容 PascalCase / camelCase 命名
o.PayloadSerializerOptions.PropertyNameCaseInsensitive = true;
});
services.AddSingleton();
// —— JWT 鉴权:复用生产 OnMessageReceived,使 WebSocket 握手能从 query 取 token ——
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.MapInboundClaims = false;
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(SigningKey)),
NameClaimType = JwtRegisteredClaimNames.UniqueName,
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var token = context.Request.Query["access_token"];
if (!string.IsNullOrEmpty(token) && context.Request.Path.StartsWithSegments("/hubs"))
context.Token = token;
return Task.CompletedTask;
}
};
});
services.AddAuthorization();
_app = builder.Build();
_app.UseRouting();
_app.UseAuthentication();
_app.UseAuthorization();
_app.MapHub("/hubs/chat");
await _app.StartAsync();
// UseTestServer 后通过 GetTestServer 扩展取得内存测试服务器实例(暴露 BaseAddress 与 handler)
TestServer = _app.GetTestServer();
// 建库建表(幂等):用模型直接生成 schema,无需迁移文件
await using (var scope = _app.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService();
await db.Database.EnsureDeletedAsync();
await db.Database.EnsureCreatedAsync();
}
await CleanRedisAsync();
}
public async Task DisposeAsync()
{
if (_app is not null)
{
await _app.StopAsync();
await _app.DisposeAsync();
}
_redis?.Dispose();
}
/// 每个测试用例前调用:清空 Redis 上 IM 相关键,避免未读/在线状态串测。
public async Task CleanRedisAsync()
{
if (_redis is null) return;
var server = _redis.GetServer(_redis.GetEndPoints()[0]);
foreach (var key in server.Keys(pattern: "im:online:*").Concat(server.Keys(pattern: "im:unread:*")))
{
await _redis.GetDatabase().KeyDeleteAsync(key);
}
}
/// 为指定用户签发 JWT(sub + unique_name),用于 SignalR 握手鉴权。
public string IssueToken(Guid userId, string userName)
{
var handler = new JsonWebTokenHandler();
var token = handler.CreateToken(new SecurityTokenDescriptor
{
Issuer = "im-test",
Claims = new Dictionary
{
[JwtRegisteredClaimNames.Sub] = userId.ToString(),
[JwtRegisteredClaimNames.UniqueName] = userName,
},
Expires = DateTime.UtcNow.AddMinutes(30),
SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(SigningKey)),
SecurityAlgorithms.HmacSha256)
});
return token;
}
}