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; } }