234 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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;
using IM.Domain.Common;
using IM.Infrastructure.Cache;
using IM.Infrastructure.Persistence;
using StackExchange.Redis;
namespace BuildFlow.IM.Integration.Tests;
/// <summary>
/// 集成测试夹具:在 TestServer 上装配真实 SignalR ChatHub + 真实 PostgreSQL / MongoDB / Redis,
/// 仅替换用户摘要查询和通知发布为进程内 fake,使测试聚焦于"SignalR + DB"主链路。
/// </summary>
/// <remarks>
/// 设计取舍:
/// <list type="bullet">
/// <item>不复用 <c>Program.cs</c> 的 ABP 全量引导(Swagger/OTel/Seed 会对测试引入噪声与不确定性),
/// 而是手工注册 IM 模块所需服务,确保用例快、稳、可重复。</item>
/// <item>连接串指向独立的测试库(im_system_test / im_system_test),避免污染开发库的种子数据。</item>
/// <item>Redis 共用同一实例,但用例执行前按前缀清理 <c>im:online:*</c> / <c>im:unread:*</c>,保证未读计数与在线状态隔离。</item>
/// </list>
/// </remarks>
public sealed class ImServerFixture : IAsyncLifetime
{
/// <summary>本地基础设施回退连接串。SOP 通过环境变量覆盖并指向随机端口容器。</summary>
private const string DefaultPostgresConnection = "Host=localhost;Port=5432;Database=im_system_test;Username=rag;Password=rag123";
private const string DefaultMongoConnection = "mongodb://mongo:mongo123@localhost:27017/im_system_test?authSource=admin";
private const string DefaultRedisConnection = "localhost:6379,abortConnect=false";
/// <summary>JWT 对称密钥,与 appsettings.json 中的 Jwt:SigningKey 保持一致,使 Hub 验签通过。</summary>
private const string SigningKey = "RagJwtSecretKey2026MustBeAtLeast32CharsLong!";
/// <summary>测试用固定用户:发送者 / 接收者 / 群管理员 / 局外人。</summary>
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;
/// <summary>TestServer 实例:SignalR 客户端据其 BaseAddress 与 handler 建立内存通道连接。</summary>
public TestServer TestServer { get; private set; } = null!;
/// <summary>TestServer 的基础地址,SignalR 客户端据此拼装 hub URL。</summary>
public Uri BaseAddress => TestServer.BaseAddress;
/// <summary>应用根服务容器,供播种/清理助手解析 DbContext 等。</summary>
public IServiceProvider Services => _app!.Services;
/// <summary>创建可复用的 HttpClient(携带 TestServer 的 handler)。</summary>
public HttpMessageHandler CreateMessageHandler() => TestServer.CreateHandler();
/// <summary>外部通知发布的 fake:记录最后一次调用参数,便于断言离线触达。</summary>
public FakeImNotificationPublisher NotificationPublisher { get; } = new();
/// <summary>Platform 用户摘要查询 fake:返回预设的用户名和头像。</summary>
public InMemoryUserSummaryQueryHandler UserSummaryHandler { get; } = new();
public async Task InitializeAsync()
{
var postgresConnection = Environment.GetEnvironmentVariable("BUILDFLOW_IM_TEST_POSTGRES_CONNECTION")
?? DefaultPostgresConnection;
var mongoConnection = Environment.GetEnvironmentVariable("BUILDFLOW_IM_TEST_MONGO_CONNECTION")
?? DefaultMongoConnection;
var redisConnection = Environment.GetEnvironmentVariable("BUILDFLOW_IM_TEST_REDIS_CONNECTION")
?? DefaultRedisConnection;
_redis = await ConnectionMultiplexer.ConnectAsync(redisConnection);
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:Default"] = postgresConnection,
["ConnectionStrings:MongoDb"] = mongoConnection,
});
var services = builder.Services;
// —— PostgreSQL:会话元数据,挂真实 AuditInterceptor(用 fake 当前用户) ——
services.TryAddSingleton<ICurrentUserContext, TestCurrentUserContext>();
services.AddScoped<AuditInterceptor>();
services.AddDbContext<ImDbContext>((sp, options) =>
options.UseNpgsql(postgresConnection)
.AddInterceptors(sp.GetRequiredService<AuditInterceptor>()));
// —— MongoDB:消息正文(MongoDB.Entities 静态 DB.Default)——
var mongoDbName = MongoUrl.Create(mongoConnection).DatabaseName;
DB.InitAsync(mongoDbName, MongoClientSettings.FromConnectionString(mongoConnection)).GetAwaiter().GetResult();
// —— Redis:在线状态与未读计数的真实实现 ——
services.AddSingleton<IConnectionMultiplexer>(_ => _redis!);
services.AddSingleton<IOnlinePresenceService, RedisOnlinePresenceService>();
services.AddSingleton<IUnreadCounter, RedisUnreadCounter>();
// —— 外部通知依赖替换为进程内 fake ——
services.AddSingleton<IImNotificationPublisher>(NotificationPublisher);
// —— MediatR + 校验管线(与生产 Application 模块一致) ——
var assembly = typeof(SendMessageCommand).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 =>
{
options.AddFilter<DomainExceptionHubFilter>();
// TestServer 内存传输对断开检测较慢;缩短 keep-alive 间隔,使 OnDisconnectedAsync 更快被触发
options.KeepAliveInterval = TimeSpan.FromSeconds(1);
options.ClientTimeoutInterval = TimeSpan.FromSeconds(3);
})
.AddJsonProtocol(o =>
{
// 测试客户端用强类型载荷解析,开启忽略大小写以兼容 PascalCase / camelCase 命名
o.PayloadSerializerOptions.PropertyNameCaseInsensitive = true;
});
services.AddSingleton<DomainExceptionHubFilter>();
// —— 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<ChatHub>("/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<ImDbContext>();
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();
}
/// <summary>每个测试用例前调用:清空 Redis 上 IM 相关键,避免未读/在线状态串测。</summary>
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);
}
}
/// <summary>为指定用户签发 JWT(sub + unique_name),用于 SignalR 握手鉴权。</summary>
public string IssueToken(Guid userId, string userName)
{
var handler = new JsonWebTokenHandler();
var token = handler.CreateToken(new SecurityTokenDescriptor
{
Issuer = "im-test",
Claims = new Dictionary<string, object>
{
[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;
}
}