fengling-gateway/tests/YarpGateway.Tests/Integration/ConfigReloadTests.cs
movingsam 9b77169b80 test: add end-to-end integration tests (IMPL-12)
- TestFixture: Base test infrastructure with WebApplicationFactory
- K8sDiscoveryTests: K8s Service Label discovery flow tests
- ConfigConfirmationTests: Pending config confirmation flow tests
- MultiTenantRoutingTests: Tenant-specific vs default destination routing tests
- ConfigReloadTests: Gateway hot-reload via NOTIFY mechanism tests
- TestData: Mock data for K8s services, JWT tokens, database seeding

Tests cover:
1. K8s Service discovery with valid labels
2. Config confirmation -> DB write -> NOTIFY
3. Multi-tenant routing (dedicated vs default destination)
4. Gateway config hot-reload without restart
2026-03-08 10:53:19 +08:00

479 lines
14 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 System.Collections.Concurrent;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Xunit;
using YarpGateway.Data;
using YarpGateway.DynamicProxy;
using YarpGateway.Services;
using Fengling.Platform.Domain.AggregatesModel.GatewayAggregate;
namespace YarpGateway.Tests.Integration;
/// <summary>
/// 网关配置热重载集成测试
/// </summary>
[Collection("Integration Tests")]
public class ConfigReloadTests : IDisposable
{
private readonly TestFixture _fixture;
private readonly GatewayDbContext _dbContext;
public ConfigReloadTests(TestFixture fixture)
{
_fixture = fixture;
_dbContext = _fixture.CreateDbContext();
}
public void Dispose()
{
_dbContext.Dispose();
}
#region
[Fact]
public async Task WhenConfigChanged_ReloadAsync_ShouldUpdateRouteCache()
{
// Arrange: 获取初始路由数量
var routeCache = _fixture.GetRouteCache();
var initialRoute = routeCache.GetRoute("test-tenant", "reload-test");
initialRoute.Should().BeNull(); // 初始时不存在
// 添加新路由
var newRoute = new GwTenantRoute
{
Id = Guid.CreateVersion7().ToString("N"),
TenantCode = "test-tenant",
ServiceName = "reload-test",
ClusterId = "member-cluster",
Match = new GwRouteMatch { Path = "/api/reload-test/**" },
Priority = 1,
Status = 1,
IsGlobal = false,
CreatedTime = DateTime.UtcNow
};
_dbContext.GwTenantRoutes.Add(newRoute);
await _dbContext.SaveChangesAsync();
// Act: 重新加载配置
await routeCache.ReloadAsync();
// Assert: 应该能找到新路由
var reloadedRoute = routeCache.GetRoute("test-tenant", "reload-test");
reloadedRoute.Should().NotBeNull();
reloadedRoute!.ClusterId.Should().Be("member-cluster");
// 清理
_dbContext.GwTenantRoutes.Remove(newRoute);
await _dbContext.SaveChangesAsync();
}
[Fact]
public async Task WhenConfigChanged_ReloadAsync_ShouldUpdateProxyConfig()
{
// Arrange
var configProvider = _fixture.GetConfigProvider();
var initialConfig = configProvider.GetConfig();
var initialRouteCount = initialConfig.Routes.Count;
// 添加新路由
var newRoute = new GwTenantRoute
{
Id = Guid.CreateVersion7().ToString("N"),
TenantCode = "",
ServiceName = "proxy-reload-test",
ClusterId = "member-cluster",
Match = new GwRouteMatch { Path = "/api/proxy-reload/**" },
Priority = 1,
Status = 1,
IsGlobal = true,
CreatedTime = DateTime.UtcNow
};
_dbContext.GwTenantRoutes.Add(newRoute);
await _dbContext.SaveChangesAsync();
// 重新加载 RouteCache
var routeCache = _fixture.GetRouteCache();
await routeCache.ReloadAsync();
// Act: 更新代理配置
configProvider.UpdateConfig();
// Assert
var newConfig = configProvider.GetConfig();
newConfig.Routes.Count.Should().BeGreaterThanOrEqualTo(initialRouteCount);
// 验证可以通过服务名找到路由
var route = newConfig.Routes.FirstOrDefault(r =>
r.Metadata?.ContainsKey("ServiceName") == true &&
r.Metadata["ServiceName"] == "proxy-reload-test");
route.Should().NotBeNull();
// 清理
_dbContext.GwTenantRoutes.Remove(newRoute);
await _dbContext.SaveChangesAsync();
}
[Fact]
public async Task WhenRouteDisabled_Reload_ShouldRemoveFromCache()
{
// Arrange: 添加并确认路由存在
var routeId = Guid.CreateVersion7().ToString("N");
var newRoute = new GwTenantRoute
{
Id = routeId,
TenantCode = "disable-test",
ServiceName = "disable-test-service",
ClusterId = "member-cluster",
Match = new GwRouteMatch { Path = "/api/disable/**" },
Priority = 1,
Status = 1,
IsGlobal = false,
CreatedTime = DateTime.UtcNow
};
_dbContext.GwTenantRoutes.Add(newRoute);
await _dbContext.SaveChangesAsync();
var routeCache = _fixture.GetRouteCache();
await routeCache.ReloadAsync();
// 确认路由存在
var routeBefore = routeCache.GetRoute("disable-test", "disable-test-service");
routeBefore.Should().NotBeNull();
// Act: 禁用路由
newRoute.Status = 0;
_dbContext.GwTenantRoutes.Update(newRoute);
await _dbContext.SaveChangesAsync();
await routeCache.ReloadAsync();
// Assert: 路由应该从缓存中移除
var routeAfter = routeCache.GetRoute("disable-test", "disable-test-service");
routeAfter.Should().BeNull();
// 清理
_dbContext.GwTenantRoutes.Remove(newRoute);
await _dbContext.SaveChangesAsync();
}
[Fact]
public async Task WhenRouteDeleted_Reload_ShouldRemoveFromCache()
{
// Arrange
var routeId = Guid.CreateVersion7().ToString("N");
var newRoute = new GwTenantRoute
{
Id = routeId,
TenantCode = "delete-test",
ServiceName = "delete-test-service",
ClusterId = "member-cluster",
Match = new GwRouteMatch { Path = "/api/delete/**" },
Priority = 1,
Status = 1,
IsGlobal = false,
IsDeleted = false,
CreatedTime = DateTime.UtcNow
};
_dbContext.GwTenantRoutes.Add(newRoute);
await _dbContext.SaveChangesAsync();
var routeCache = _fixture.GetRouteCache();
await routeCache.ReloadAsync();
// 确认路由存在
var routeBefore = routeCache.GetRoute("delete-test", "delete-test-service");
routeBefore.Should().NotBeNull();
// Act: 软删除路由
newRoute.IsDeleted = true;
_dbContext.GwTenantRoutes.Update(newRoute);
await _dbContext.SaveChangesAsync();
await routeCache.ReloadAsync();
// Assert: 路由应该从缓存中移除
var routeAfter = routeCache.GetRoute("delete-test", "delete-test-service");
routeAfter.Should().BeNull();
// 清理
_dbContext.GwTenantRoutes.Remove(newRoute);
await _dbContext.SaveChangesAsync();
}
[Fact]
public async Task WhenClusterChanged_Reload_ShouldUpdateClusterConfig()
{
// Arrange
var configProvider = _fixture.GetConfigProvider();
// 添加新集群
var newCluster = new GwCluster
{
Id = Guid.CreateVersion7().ToString("N"),
ClusterId = "reload-test-cluster",
Name = "Reload Test Cluster",
Status = 1,
CreatedTime = DateTime.UtcNow,
LoadBalancingPolicy = GwLoadBalancingPolicy.RoundRobin,
Destinations = new List<GwDestination>
{
new()
{
DestinationId = "dest-1",
Address = "http://reload-test:8080",
Weight = 1,
Status = 1
}
}
};
_dbContext.GwClusters.Add(newCluster);
await _dbContext.SaveChangesAsync();
// Act
await _fixture.ReloadConfigurationAsync();
// Assert
var config = configProvider.GetConfig();
var cluster = config.Clusters.FirstOrDefault(c => c.ClusterId == "reload-test-cluster");
cluster.Should().NotBeNull();
cluster!.Destinations.Should().ContainKey("dest-1");
// 清理
_dbContext.GwClusters.Remove(newCluster);
await _dbContext.SaveChangesAsync();
}
[Fact]
public async Task WhenDestinationAdded_Reload_ShouldIncludeNewDestination()
{
// Arrange: 获取现有集群
var cluster = await _dbContext.GwClusters
.Include(c => c.Destinations)
.FirstOrDefaultAsync(c => c.ClusterId == "member-cluster");
cluster.Should().NotBeNull();
var initialDestCount = cluster!.Destinations.Count;
// 添加新目标
var newDest = new GwDestination
{
DestinationId = "new-dest",
Address = "http://new-destination:8080",
Weight = 1,
Status = 1
};
cluster.Destinations.Add(newDest);
await _dbContext.SaveChangesAsync();
// Act
await _fixture.ReloadConfigurationAsync();
// Assert
var configProvider = _fixture.GetConfigProvider();
var config = configProvider.GetConfig();
var updatedCluster = config.Clusters.FirstOrDefault(c => c.ClusterId == "member-cluster");
updatedCluster.Should().NotBeNull();
updatedCluster!.Destinations.Should().ContainKey("new-dest");
// 清理
cluster.Destinations.Remove(newDest);
await _dbContext.SaveChangesAsync();
}
#endregion
#region
[Fact]
public async Task ConcurrentReload_ShouldBeThreadSafe()
{
// Arrange
var routeCache = _fixture.GetRouteCache();
var configProvider = _fixture.GetConfigProvider();
// Act: 并发执行多次重载
var tasks = Enumerable.Range(0, 10)
.Select(_ => Task.Run(async () =>
{
await routeCache.ReloadAsync();
configProvider.UpdateConfig();
}))
.ToList();
// 不应该抛出异常
await Task.WhenAll(tasks);
// Assert: 配置应该保持一致
var config = configProvider.GetConfig();
config.Should().NotBeNull();
config.Routes.Should().NotBeNull();
config.Clusters.Should().NotBeNull();
}
#endregion
#region
[Fact]
public async Task SimulateNotify_ShouldTriggerReload()
{
// Arrange
var routeCache = _fixture.GetRouteCache();
var configProvider = _fixture.GetConfigProvider();
// 添加新路由
var newRoute = new GwTenantRoute
{
Id = Guid.CreateVersion7().ToString("N"),
TenantCode = "",
ServiceName = "notify-test",
ClusterId = "member-cluster",
Match = new GwRouteMatch { Path = "/api/notify/**" },
Priority = 1,
Status = 1,
IsGlobal = true,
CreatedTime = DateTime.UtcNow
};
_dbContext.GwTenantRoutes.Add(newRoute);
await _dbContext.SaveChangesAsync();
// Act: 模拟接收到 NOTIFY 事件后手动触发重载
// 在真实环境中,这是由 PgSqlConfigChangeListener 自动处理的
await routeCache.ReloadAsync();
configProvider.UpdateConfig();
// Assert
var route = routeCache.GetRoute("any-tenant", "notify-test");
route.Should().NotBeNull();
route!.ClusterId.Should().Be("test-cluster");
// 清理
_dbContext.GwTenantRoutes.Remove(newRoute);
await _dbContext.SaveChangesAsync();
}
[Fact]
public async Task ConfigChangeToken_ShouldSignalChange()
{
// Arrange
var configProvider = _fixture.GetConfigProvider();
var config = configProvider.GetConfig();
// 获取变更令牌
var changeToken = config.ChangeToken;
var changeDetected = false;
// 注册变更回调
using var registration = changeToken.RegisterChangeCallback(_ =>
{
changeDetected = true;
}, null);
// Act: 添加新路由并更新配置
var newRoute = new GwTenantRoute
{
Id = Guid.CreateVersion7().ToString("N"),
TenantCode = "",
ServiceName = "token-test",
ClusterId = "member-cluster",
Match = new GwRouteMatch { Path = "/api/token/**" },
Priority = 1,
Status = 1,
IsGlobal = true,
CreatedTime = DateTime.UtcNow
};
_dbContext.GwTenantRoutes.Add(newRoute);
await _dbContext.SaveChangesAsync();
await _fixture.ReloadConfigurationAsync();
// 获取新配置
var newConfig = configProvider.GetConfig();
// Assert: 新配置的 ChangeToken 应该与老配置不同
newConfig.ChangeToken.Should().NotBeSameAs(changeToken);
// 清理
_dbContext.GwTenantRoutes.Remove(newRoute);
await _dbContext.SaveChangesAsync();
}
#endregion
#region
[Fact]
public async Task ReloadPerformance_ShouldBeFast()
{
// Arrange
var routeCache = _fixture.GetRouteCache();
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
// Act: 执行多次重载
for (int i = 0; i < 10; i++)
{
await routeCache.ReloadAsync();
}
stopwatch.Stop();
// Assert: 10 次重载应该在合理时间内完成
stopwatch.ElapsedMilliseconds.Should().BeLessThan(5000);
}
[Fact]
public async Task LargeConfigReload_ShouldHandleGracefully()
{
// Arrange: 批量添加路由
var routes = new List<GwTenantRoute>();
for (int i = 0; i < 100; i++)
{
routes.Add(new GwTenantRoute
{
Id = Guid.CreateVersion7().ToString("N"),
TenantCode = $"perf-tenant-{i % 10}",
ServiceName = $"perf-service-{i}",
ClusterId = "member-cluster",
Match = new GwRouteMatch { Path = $"/api/perf-{i}/**" },
Priority = 1,
Status = 1,
IsGlobal = i % 10 == 0,
CreatedTime = DateTime.UtcNow
});
}
_dbContext.GwTenantRoutes.AddRange(routes);
await _dbContext.SaveChangesAsync();
var routeCache = _fixture.GetRouteCache();
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
// Act
await routeCache.ReloadAsync();
stopwatch.Stop();
// Assert: 100 条路由的重载应该在合理时间内完成
stopwatch.ElapsedMilliseconds.Should().BeLessThan(5000);
// 验证路由已加载
var route = routeCache.GetRoute("perf-tenant-0", "perf-service-0");
route.Should().NotBeNull();
// 清理
_dbContext.GwTenantRoutes.RemoveRange(routes);
await _dbContext.SaveChangesAsync();
}
#endregion
}