using System.Net; using System.Security.Claims; using Ardalis.GuardClauses; using BuildingBlocks.Core.Event; using BuildingBlocks.Core.Model; using BuildingBlocks.EFCore; using BuildingBlocks.Mongo; using BuildingBlocks.PersistMessageProcessor; using BuildingBlocks.Web; using Duende.IdentityServer.EntityFramework.Entities; using EasyNetQ.Management.Client; using Grpc.Net.Client; using MassTransit; using MassTransit.Testing; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using MongoDB.Driver; using NSubstitute; using Respawn; using Serilog; using WebMotions.Fake.Authentication.JwtBearer; using Xunit; using Xunit.Abstractions; using ILogger = Serilog.ILogger; namespace BuildingBlocks.TestBase; using System.Globalization; using Npgsql; using Testcontainers.EventStoreDb; using Testcontainers.MongoDb; using Testcontainers.PostgreSql; using Testcontainers.RabbitMq; public class TestFixture : IAsyncLifetime where TEntryPoint : class { private readonly WebApplicationFactory _factory; private int Timeout => 120; // Second private ITestHarness TestHarness => ServiceProvider?.GetTestHarness(); private Action TestRegistrationServices { get; set; } private PostgreSqlContainer PostgresTestcontainer; private PostgreSqlContainer PostgresPersistTestContainer; public RabbitMqContainer RabbitMqTestContainer; public MongoDbContainer MongoDbTestContainer; public EventStoreDbContainer EventStoreDbTestContainer; public CancellationTokenSource CancellationTokenSource; public PersistMessageBackgroundService PersistMessageBackgroundService => ServiceProvider.GetRequiredService(); public HttpClient HttpClient { get { var claims = new Dictionary { { ClaimTypes.Name, "test@sample.com" }, { ClaimTypes.Role, "admin" }, { "scope", "flight-api" } }; var httpClient = _factory.CreateClient(); httpClient.SetFakeBearerToken(claims); // Uses FakeJwtBearer return httpClient; } } public GrpcChannel Channel => GrpcChannel.ForAddress( HttpClient.BaseAddress!, new GrpcChannelOptions { HttpClient = HttpClient }); public IServiceProvider ServiceProvider => _factory?.Services; public IConfiguration Configuration => _factory?.Services.GetRequiredService(); public ILogger Logger { get; set; } protected TestFixture() { _factory = new WebApplicationFactory() .WithWebHostBuilder( builder => { builder.ConfigureAppConfiguration(AddCustomAppSettings); builder.UseEnvironment("test"); builder.ConfigureServices( services => { TestRegistrationServices?.Invoke(services); services.ReplaceSingleton(AddHttpContextAccessorMock); services.AddSingleton(); services.RemoveHostedService(); // Register all ITestDataSeeder implementations dynamically services.Scan(scan => scan .FromApplicationDependencies() // Scan the current app and its dependencies .AddClasses(classes => classes.AssignableTo()) // Find classes that implement ITestDataSeeder .AsImplementedInterfaces() .WithScopedLifetime()); // Add Fake JWT Authentication - we can use SetAdminUser method to set authenticate user to existing HttContextAccessor // https://github.com/webmotions/fake-authentication-jwtbearer // https://github.com/webmotions/fake-authentication-jwtbearer/issues/14 services.AddAuthentication( options => { options.DefaultAuthenticateScheme = FakeJwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = FakeJwtBearerDefaults.AuthenticationScheme; }) .AddFakeJwtBearer(); // Mock Authorization Policies services.AddAuthorization(options => { options.AddPolicy(nameof(ApiScope), policy => { policy.AddAuthenticationSchemes(FakeJwtBearerDefaults.AuthenticationScheme); policy.RequireAuthenticatedUser(); policy.RequireClaim("scope", "flight-api"); // Test-specific scope }); }); }); }); } public async Task InitializeAsync() { CancellationTokenSource = new CancellationTokenSource(); await StartTestContainerAsync(); } public async Task DisposeAsync() { await StopTestContainerAsync(); await _factory.DisposeAsync(); await CancellationTokenSource.CancelAsync(); } public virtual void RegisterServices(Action services) { TestRegistrationServices += services; } // ref: https://github.com/trbenning/serilog-sinks-xunit public ILogger CreateLogger(ITestOutputHelper output) { if (output != null) { return new LoggerConfiguration() .WriteTo.TestOutput(output) .CreateLogger(); } return null; } protected async Task ExecuteScopeAsync(Func action) { using var scope = ServiceProvider.CreateScope(); await action(scope.ServiceProvider); } protected async Task ExecuteScopeAsync(Func> action) { using var scope = ServiceProvider.CreateScope(); var result = await action(scope.ServiceProvider); return result; } public Task SendAsync(IRequest request) { return ExecuteScopeAsync( sp => { var mediator = sp.GetRequiredService(); return mediator.Send(request); }); } public Task SendAsync(IRequest request) { return ExecuteScopeAsync( sp => { var mediator = sp.GetRequiredService(); return mediator.Send(request); }); } public async Task Publish( TMessage message, CancellationToken cancellationToken = default ) where TMessage : class, IEvent { await TestHarness.Bus.Publish(message, cancellationToken); } public async Task WaitForPublishing( CancellationToken cancellationToken = default ) where TMessage : class, IEvent { var result = await WaitUntilConditionMet( async () => { var published = await TestHarness.Published.Any(cancellationToken); return published; }); return result; } public async Task WaitForConsuming( CancellationToken cancellationToken = default ) where TMessage : class, IEvent { var result = await WaitUntilConditionMet( async () => { var consumed = await TestHarness.Consumed.Any(cancellationToken); return consumed; }); return result; } public async Task ShouldProcessedPersistInternalCommand( CancellationToken cancellationToken = default ) where TInternalCommand : class, IInternalCommand { var result = await WaitUntilConditionMet( async () => { return await ExecuteScopeAsync( async sp => { var persistMessageProcessor = sp.GetService(); Guard.Against.Null( persistMessageProcessor, nameof(persistMessageProcessor)); var filter = await persistMessageProcessor.GetByFilterAsync( x => x.DeliveryType == MessageDeliveryType.Internal && typeof(TInternalCommand).ToString() == x.DataType); var res = filter.Any( x => x.MessageStatus == MessageStatus.Processed); return res; }); }); return result; } // Ref: https://tech.energyhelpline.com/in-memory-testing-with-masstransit/ private async Task WaitUntilConditionMet( Func> conditionToMet, int? timeoutSecond = null ) { var time = timeoutSecond ?? Timeout; var startTime = DateTime.Now; var timeoutExpired = false; var meet = await conditionToMet.Invoke(); while (!meet) { if (timeoutExpired) { return false; } await Task.Delay(100); meet = await conditionToMet.Invoke(); timeoutExpired = DateTime.Now - startTime > TimeSpan.FromSeconds(time); } return true; } private async Task StartTestContainerAsync() { PostgresTestcontainer = TestContainers.PostgresTestContainer(); PostgresPersistTestContainer = TestContainers.PostgresPersistTestContainer(); RabbitMqTestContainer = TestContainers.RabbitMqTestContainer(); MongoDbTestContainer = TestContainers.MongoTestContainer(); EventStoreDbTestContainer = TestContainers.EventStoreTestContainer(); await MongoDbTestContainer.StartAsync(); await PostgresTestcontainer.StartAsync(); await PostgresPersistTestContainer.StartAsync(); await RabbitMqTestContainer.StartAsync(); await EventStoreDbTestContainer.StartAsync(); } private async Task StopTestContainerAsync() { await PostgresTestcontainer.StopAsync(); await PostgresPersistTestContainer.StopAsync(); await RabbitMqTestContainer.StopAsync(); await MongoDbTestContainer.StopAsync(); await EventStoreDbTestContainer.StopAsync(); } private void AddCustomAppSettings(IConfigurationBuilder configuration) { configuration.AddInMemoryCollection( new KeyValuePair[] { new( "PostgresOptions:ConnectionString", PostgresTestcontainer.GetConnectionString()), new( "PersistMessageOptions:ConnectionString", PostgresPersistTestContainer.GetConnectionString()), new("RabbitMqOptions:HostName", RabbitMqTestContainer.Hostname), new( "RabbitMqOptions:UserName", TestContainers.RabbitMqContainerConfiguration.UserName), new( "RabbitMqOptions:Password", TestContainers.RabbitMqContainerConfiguration.Password), new( "RabbitMqOptions:Port", RabbitMqTestContainer.GetMappedPublicPort( TestContainers.RabbitMqContainerConfiguration.Port) .ToString(NumberFormatInfo.InvariantInfo)), new("MongoOptions:ConnectionString", MongoDbTestContainer.GetConnectionString()), new("MongoOptions:DatabaseName", TestContainers.MongoContainerConfiguration.Name), new( "EventStoreOptions:ConnectionString", EventStoreDbTestContainer.GetConnectionString()) }); } private IHttpContextAccessor AddHttpContextAccessorMock(IServiceProvider serviceProvider) { var httpContextAccessorMock = Substitute.For(); using var scope = serviceProvider.CreateScope(); httpContextAccessorMock.HttpContext = new DefaultHttpContext { RequestServices = scope.ServiceProvider }; httpContextAccessorMock.HttpContext.Request.Host = new HostString("localhost", 6012); httpContextAccessorMock.HttpContext.Request.Scheme = "http"; return httpContextAccessorMock; } } public class TestWriteFixture : TestFixture where TEntryPoint : class where TWContext : DbContext { public Task ExecuteDbContextAsync(Func action) { return ExecuteScopeAsync(sp => action(sp.GetService())); } public Task ExecuteDbContextAsync(Func action) { return ExecuteScopeAsync(sp => action(sp.GetService()).AsTask()); } public Task ExecuteDbContextAsync(Func action) { return ExecuteScopeAsync( sp => action(sp.GetService(), sp.GetService())); } public Task ExecuteDbContextAsync(Func> action) { return ExecuteScopeAsync(sp => action(sp.GetService())); } public Task ExecuteDbContextAsync(Func> action) { return ExecuteScopeAsync(sp => action(sp.GetService()).AsTask()); } public Task ExecuteDbContextAsync(Func> action) { return ExecuteScopeAsync( sp => action(sp.GetService(), sp.GetService())); } public Task InsertAsync(params T[] entities) where T : class { return ExecuteDbContextAsync( db => { foreach (var entity in entities) { db.Set().Add(entity); } return db.SaveChangesAsync(); }); } public async Task InsertAsync(TEntity entity) where TEntity : class { await ExecuteDbContextAsync( db => { db.Set().Add(entity); return db.SaveChangesAsync(); }); } public Task InsertAsync(TEntity entity, TEntity2 entity2) where TEntity : class where TEntity2 : class { return ExecuteDbContextAsync( db => { db.Set().Add(entity); db.Set().Add(entity2); return db.SaveChangesAsync(); }); } public Task InsertAsync( TEntity entity, TEntity2 entity2, TEntity3 entity3 ) where TEntity : class where TEntity2 : class where TEntity3 : class { return ExecuteDbContextAsync( db => { db.Set().Add(entity); db.Set().Add(entity2); db.Set().Add(entity3); return db.SaveChangesAsync(); }); } public Task InsertAsync( TEntity entity, TEntity2 entity2, TEntity3 entity3, TEntity4 entity4 ) where TEntity : class where TEntity2 : class where TEntity3 : class where TEntity4 : class { return ExecuteDbContextAsync( db => { db.Set().Add(entity); db.Set().Add(entity2); db.Set().Add(entity3); db.Set().Add(entity4); return db.SaveChangesAsync(); }); } public Task FindAsync(TKey id) where T : class, IEntity { return ExecuteDbContextAsync(db => db.Set().FindAsync(id).AsTask()); } public Task FirstOrDefaultAsync() where T : class, IEntity { return ExecuteDbContextAsync(db => db.Set().FirstOrDefaultAsync()); } } public class TestReadFixture : TestFixture where TEntryPoint : class where TRContext : MongoDbContext { public Task ExecuteReadContextAsync(Func action) { return ExecuteScopeAsync(sp => action(sp.GetRequiredService())); } public Task ExecuteReadContextAsync(Func> action) { return ExecuteScopeAsync(sp => action(sp.GetRequiredService())); } public async Task InsertMongoDbContextAsync(string collectionName, params T[] entities) where T : class { await ExecuteReadContextAsync( async db => { await db.GetCollection(collectionName).InsertManyAsync(entities.ToList()); }); } } public class TestFixture : TestWriteFixture where TEntryPoint : class where TWContext : DbContext where TRContext : MongoDbContext { public Task ExecuteReadContextAsync(Func action) { return ExecuteScopeAsync(sp => action(sp.GetRequiredService())); } public Task ExecuteReadContextAsync(Func> action) { return ExecuteScopeAsync(sp => action(sp.GetRequiredService())); } public async Task InsertMongoDbContextAsync(string collectionName, params T[] entities) where T : class { await ExecuteReadContextAsync( async db => { await db.GetCollection(collectionName).InsertManyAsync(entities.ToList()); }); } } public class TestFixtureCore : IAsyncLifetime where TEntryPoint : class { private Respawner _reSpawnerDefaultDb; private Respawner _reSpawnerPersistDb; private NpgsqlConnection DefaultDbConnection { get; set; } private NpgsqlConnection PersistDbConnection { get; set; } public TestFixtureCore( TestFixture integrationTestFixture, ITestOutputHelper outputHelper ) { Fixture = integrationTestFixture; integrationTestFixture.RegisterServices(RegisterTestsServices); integrationTestFixture.Logger = integrationTestFixture.CreateLogger(outputHelper); } public TestFixture Fixture { get; } public async Task InitializeAsync() { await InitPostgresAsync(); } public async Task DisposeAsync() { await ResetPostgresAsync(); await ResetMongoAsync(); await ResetRabbitMqAsync(); } private async Task InitPostgresAsync() { var postgresOptions = Fixture.ServiceProvider.GetService(); var persistOptions = Fixture.ServiceProvider.GetService(); if (!string.IsNullOrEmpty(persistOptions?.ConnectionString)) { await Fixture.PersistMessageBackgroundService.StartAsync( Fixture.CancellationTokenSource.Token); PersistDbConnection = new NpgsqlConnection(persistOptions.ConnectionString); await PersistDbConnection.OpenAsync(); _reSpawnerPersistDb = await Respawner.CreateAsync( PersistDbConnection, new RespawnerOptions { DbAdapter = DbAdapter.Postgres }); } if (!string.IsNullOrEmpty(postgresOptions?.ConnectionString)) { DefaultDbConnection = new NpgsqlConnection(postgresOptions.ConnectionString); await DefaultDbConnection.OpenAsync(); _reSpawnerDefaultDb = await Respawner.CreateAsync( DefaultDbConnection, new RespawnerOptions { DbAdapter = DbAdapter.Postgres }); await SeedDataAsync(); } } private async Task ResetPostgresAsync() { if (PersistDbConnection is not null) { await _reSpawnerPersistDb.ResetAsync(PersistDbConnection); await Fixture.PersistMessageBackgroundService.StopAsync( Fixture.CancellationTokenSource.Token); } if (DefaultDbConnection is not null) { await _reSpawnerDefaultDb.ResetAsync(DefaultDbConnection); } } private async Task ResetMongoAsync(CancellationToken cancellationToken = default) { //https://stackoverflow.com/questions/3366397/delete-everything-in-a-mongodb-database var dbClient = new MongoClient(Fixture.MongoDbTestContainer?.GetConnectionString()); var collections = await dbClient .GetDatabase(TestContainers.MongoContainerConfiguration.Name) .ListCollectionsAsync(cancellationToken: cancellationToken); foreach (var collection in collections.ToList()) { await dbClient.GetDatabase(TestContainers.MongoContainerConfiguration.Name) .DropCollectionAsync(collection["name"].AsString, cancellationToken); } } private async Task ResetRabbitMqAsync(CancellationToken cancellationToken = default) { var port = Fixture.RabbitMqTestContainer?.GetMappedPublicPort( TestContainers.RabbitMqContainerConfiguration .ApiPort) ?? TestContainers.RabbitMqContainerConfiguration.ApiPort; var managementClient = new ManagementClient(Fixture.RabbitMqTestContainer?.Hostname, TestContainers.RabbitMqContainerConfiguration?.UserName, TestContainers.RabbitMqContainerConfiguration?.Password, port); var bd = await managementClient.GetBindingsAsync(cancellationToken); var bindings = bd.Where( x => !string.IsNullOrEmpty(x.Source) && !string.IsNullOrEmpty(x.Destination)); foreach (var binding in bindings) { await managementClient.DeleteBindingAsync(binding, cancellationToken); } var queues = await managementClient.GetQueuesAsync(cancellationToken: cancellationToken); foreach (var queue in queues) { await managementClient.PurgeAsync(queue, cancellationToken); } } protected virtual void RegisterTestsServices(IServiceCollection services) { } private async Task SeedDataAsync() { using var scope = Fixture.ServiceProvider.CreateScope(); var seedManager = scope.ServiceProvider.GetService(); await seedManager.ExecuteTestSeedAsync(); } } public abstract class TestReadBase : TestFixtureCore // ,IClassFixture> where TEntryPoint : class where TRContext : MongoDbContext { protected TestReadBase( TestReadFixture integrationTestFixture, ITestOutputHelper outputHelper = null ) : base(integrationTestFixture, outputHelper) { Fixture = integrationTestFixture; } public TestReadFixture Fixture { get; } } public abstract class TestWriteBase : TestFixtureCore //,IClassFixture> where TEntryPoint : class where TWContext : DbContext { protected TestWriteBase( TestWriteFixture integrationTestFixture, ITestOutputHelper outputHelper = null ) : base(integrationTestFixture, outputHelper) { Fixture = integrationTestFixture; } public TestWriteFixture Fixture { get; } } public abstract class TestBase : TestFixtureCore //,IClassFixture> where TEntryPoint : class where TWContext : DbContext where TRContext : MongoDbContext { protected TestBase( TestFixture integrationTestFixture, ITestOutputHelper outputHelper = null ) : base(integrationTestFixture, outputHelper) { Fixture = integrationTestFixture; } public TestFixture Fixture { get; } }