- SeedData 补 6 个表单分享/导入导出权限码,修复架构测试 - 清理 git 索引中 workflow/Workflow 大小写重复路径 - ImServerFixture 恢复 filter-repo 误替换的测试库连接常量
221 lines
10 KiB
C#
221 lines
10 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// 集成测试夹具:在 TestServer 上装配真实 SignalR ChatHub + 真实 PostgreSQL / MongoDB / Redis,
|
||
/// 仅替换两个外部 gRPC 依赖(用户信息、通知发布)为进程内 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>本地基础设施连接串。指向独立测试库,与开发库(im_system)隔离。</summary>
|
||
private const string PostgresConnection = "Host=localhost;Port=5432;Database=im_system_test;Username=rag;Password=rag123";
|
||
private const string MongoConnection = "mongodb://mongo:mongo123@localhost:27017/im_system_test?authSource=admin";
|
||
private const string RedisConnection = "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>外部用户信息查询的 fake:返回预设的用户名/头像。</summary>
|
||
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<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>();
|
||
|
||
// —— 外部 gRPC 依赖替换为进程内 fake ——
|
||
services.AddSingleton<IUserInfoService>(UserInfoService);
|
||
services.AddSingleton<IImNotificationPublisher>(NotificationPublisher);
|
||
|
||
// —— MediatR + 校验管线(与生产 Application 模块一致) ——
|
||
var assembly = typeof(SendMessageCommand).Assembly;
|
||
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
|
||
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;
|
||
}
|
||
}
|