diff --git a/src/BuildingBlocks/BuildingBlocks.csproj b/src/BuildingBlocks/BuildingBlocks.csproj index fd6de72..37a8d54 100644 --- a/src/BuildingBlocks/BuildingBlocks.csproj +++ b/src/BuildingBlocks/BuildingBlocks.csproj @@ -134,4 +134,11 @@ + + + + ..\..\..\..\.nuget\packages\testcontainers\2.2.0\lib\netstandard2.1\Testcontainers.dll + + + diff --git a/src/BuildingBlocks/EFCore/DatabaseOptions.cs b/src/BuildingBlocks/EFCore/DatabaseOptions.cs index 3403154..9915201 100644 --- a/src/BuildingBlocks/EFCore/DatabaseOptions.cs +++ b/src/BuildingBlocks/EFCore/DatabaseOptions.cs @@ -1,6 +1,6 @@ namespace BuildingBlocks.EFCore; -public class SqlOptions +public class ConnectionStrings { public string DefaultConnection { get; set; } } diff --git a/src/BuildingBlocks/EFCore/Extensions.cs b/src/BuildingBlocks/EFCore/Extensions.cs index 49aa6cd..35a790d 100644 --- a/src/BuildingBlocks/EFCore/Extensions.cs +++ b/src/BuildingBlocks/EFCore/Extensions.cs @@ -18,6 +18,10 @@ public static class Extensions IConfiguration configuration) where TContext : DbContext, IDbContext { + services.AddOptions() + .Bind(configuration.GetSection(nameof(ConnectionStrings))) + .ValidateDataAnnotations(); + services.AddDbContext(options => options.UseSqlServer( configuration.GetConnectionString("DefaultConnection"), diff --git a/src/BuildingBlocks/HealthCheck/Extensions.cs b/src/BuildingBlocks/HealthCheck/Extensions.cs index d2c9f8f..421c348 100644 --- a/src/BuildingBlocks/HealthCheck/Extensions.cs +++ b/src/BuildingBlocks/HealthCheck/Extensions.cs @@ -18,7 +18,7 @@ public static class Extensions public static IServiceCollection AddCustomHealthCheck(this IServiceCollection services) { var appOptions = services.GetOptions("AppOptions"); - var sqlOptions = services.GetOptions("ConnectionStrings"); + var sqlOptions = services.GetOptions("ConnectionStrings"); var rabbitMqOptions = services.GetOptions("RabbitMq"); var mongoOptions = services.GetOptions("MongoOptions"); var logOptions = services.GetOptions("LogOptions"); diff --git a/src/BuildingBlocks/TestBase/TestContainer/IntegrationTestBase.cs b/src/BuildingBlocks/TestBase/TestContainer/IntegrationTestBase.cs new file mode 100644 index 0000000..1b9f7c2 --- /dev/null +++ b/src/BuildingBlocks/TestBase/TestContainer/IntegrationTestBase.cs @@ -0,0 +1,484 @@ +using Ardalis.GuardClauses; +using BuildingBlocks.Core.Event; +using BuildingBlocks.Core.Model; +using BuildingBlocks.EFCore; +using BuildingBlocks.MassTransit; +using BuildingBlocks.Mongo; +using BuildingBlocks.PersistMessageProcessor; +using BuildingBlocks.PersistMessageProcessor.Data; +using BuildingBlocks.Web; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Configurations; +using DotNet.Testcontainers.Containers; +using Grpc.Net.Client; +using MassTransit; +using MassTransit.Testing; +using MediatR; +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 Microsoft.Extensions.Options; +using NSubstitute; +using Serilog; +using Xunit; +using Xunit.Abstractions; + +namespace BuildingBlocks.TestBase.TestContainer; + +public class IntegrationTestFactory : IAsyncLifetime + where TEntryPoint : class +{ + private readonly WebApplicationFactory _factory; + private int Timeout => 180; + public Action TestRegistrationServices { set; get; } + public HttpClient HttpClient => _factory.CreateClient(); + public ITestHarness TestHarness => CreateHarness(); + public GrpcChannel Channel => CreateChannel(); + + public IServiceProvider ServiceProvider => _factory.Services; + public IConfiguration Configuration => _factory.Services.GetRequiredService(); + public TestcontainerDatabase ContainerSqlDatabase; + public TestcontainerDatabase ContainerSqlPersistDatabase; + public TestcontainerDatabase ContainerMongoDatabase; + public string MongoConnectionString; + public string SqlConnectionString; + public string SqlPersistConnectionString; + + public IntegrationTestFactory() + { + ContainerSqlDatabase = new TestcontainersBuilder() + .WithDatabase(new MsSqlTestcontainerConfiguration {Database = "sql_test_db", Password = "localpassword#123uuuuu"}) + .WithImage("mcr.microsoft.com/mssql/server:2017-latest") + .WithCleanUp(true) + .Build(); + + ContainerSqlPersistDatabase = new TestcontainersBuilder() + .WithDatabase(new MsSqlTestcontainerConfiguration {Database = "sql_test_persist_db", Password = "localpassword#123oooo",}) + .WithImage("mcr.microsoft.com/mssql/server:2017-latest") + .WithCleanUp(true) + .Build(); + + ContainerMongoDatabase = new TestcontainersBuilder() + .WithDatabase(new MongoDbTestcontainerConfiguration() {Database = "mongo_test_db", Username = "mongo_db", Password = "mongo_db_pass"}) + .WithImage("mongo") + .WithCleanUp(true) + .Build(); + + _factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.UseEnvironment("test"); + builder.ConfigureServices(services => + { + TestRegistrationServices?.Invoke(services); + services.ReplaceSingleton(AddHttpContextAccessorMock); + services.AddMassTransitTestHarness(x => + { + x.UsingRabbitMq((context, cfg) => + { + var rabbitMqOptions = services.GetOptions("RabbitMq"); + var host = rabbitMqOptions.HostName; + + cfg.Host(host, h => + { + h.Username(rabbitMqOptions.UserName); + h.Password(rabbitMqOptions.Password); + }); + cfg.ConfigureEndpoints(context); + }); + }); + }); + }); + } + + public async Task InitializeAsync() + { + await ContainerSqlDatabase.StartAsync(); + await ContainerSqlPersistDatabase.StartAsync(); + await ContainerMongoDatabase.StartAsync(); + + MongoConnectionString = ContainerMongoDatabase?.ConnectionString; + SqlConnectionString = ContainerSqlDatabase?.ConnectionString; + SqlPersistConnectionString = ContainerSqlPersistDatabase?.ConnectionString; + } + + public async Task DisposeAsync() + { + await ContainerSqlDatabase.StopAsync(); + await ContainerSqlPersistDatabase.StopAsync(); + await ContainerMongoDatabase.StopAsync(); + + await _factory.DisposeAsync(); + } + + 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; + } + + public async Task ExecuteScopeAsync(Func action) + { + using var scope = ServiceProvider.CreateScope(); + await action(scope.ServiceProvider); + } + + public 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); + }); + } + + // Ref: https://tech.energyhelpline.com/in-memory-testing-with-masstransit/ + public async ValueTask 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) throw new TimeoutException("Condition not met for the test."); + + await Task.Delay(100); + meet = await conditionToMet.Invoke(); + timeoutExpired = DateTime.Now - startTime > TimeSpan.FromSeconds(time); + } + } + + public async ValueTask ShouldProcessedPersistInternalCommand() + where TInternalCommand : class, IInternalCommand + { + 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; + }); + }); + } + + private ITestHarness CreateHarness() + { + var harness = ServiceProvider.GetTestHarness(); + return harness; + } + + private GrpcChannel CreateChannel() + { + return GrpcChannel.ForAddress(HttpClient.BaseAddress!, new GrpcChannelOptions {HttpClient = HttpClient}); + } + + 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 IntegrationTestFactory : IntegrationTestFactory + 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(long id) + where T : class, IAudit + { + return ExecuteDbContextAsync(db => db.Set().FindAsync(id).AsTask()); + } +} + +public class + IntegrationTestFactory : IntegrationTestFactory + where TEntryPoint : class + where TWContext : DbContext + where TRContext : MongoDbContext + where PContext : PersistMessageDbContext +{ + +} + +public class IntegrationTestFactory : IntegrationTestFactory< + TEntryPoint, + TWContext> + 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 class IntegrationTestFixtureCore : IAsyncLifetime + where TEntryPoint : class +{ + private string MongoConnectionString + { + get => Fixture.ServiceProvider.GetRequiredService>()?.Value?.ConnectionString; + set => Fixture.ServiceProvider.GetRequiredService>().Value.ConnectionString = value; + } + + private string PersistConnectionString + { + get => Fixture.ServiceProvider.GetRequiredService>()?.Value.ConnectionString; + set => Fixture.ServiceProvider.GetRequiredService>().Value.ConnectionString = value; + } + + private string DefaultConnectionString + { + get => Fixture.ServiceProvider.GetRequiredService>()?.Value.DefaultConnection; + set => Fixture.ServiceProvider.GetRequiredService>().Value.DefaultConnection = value; + } + + public IntegrationTestFixtureCore(IntegrationTestFactory integrationTestFixture) + { + Fixture = integrationTestFixture; + integrationTestFixture.RegisterServices(services => RegisterTestsServices(services)); + } + + public IntegrationTestFactory Fixture { get; } + + public async Task InitializeAsync() + { + + MongoConnectionString = Fixture.MongoConnectionString; + DefaultConnectionString = Fixture.SqlConnectionString; + PersistConnectionString = Fixture.SqlPersistConnectionString; + + await SeedDataAsync(); + } + + public async Task DisposeAsync() + { + await Task.CompletedTask; + } + + protected virtual void RegisterTestsServices(IServiceCollection services) + { + } + + private async Task SeedDataAsync() + { + using var scope = Fixture.ServiceProvider.CreateScope(); + + var seeders = scope.ServiceProvider.GetServices(); + foreach (var seeder in seeders) await seeder.SeedAllAsync(); + } +} + +public abstract class IntegrationTestBase : IntegrationTestFixtureCore, + IClassFixture> + where TEntryPoint : class +{ + protected IntegrationTestBase( + IntegrationTestFactory integrationTestFixture) : base(integrationTestFixture) + { + Fixture = integrationTestFixture; + } + + public new IntegrationTestFactory Fixture { get; } +} + +public abstract class IntegrationTestBase : IntegrationTestFixtureCore, + IClassFixture> + where TEntryPoint : class + where TWContext : DbContext +{ + protected IntegrationTestBase( + IntegrationTestFactory integrationTestFixture) : base(integrationTestFixture) + { + Fixture = integrationTestFixture; + } + + public new IntegrationTestFactory Fixture { get; } +} + +public abstract class IntegrationTestBase : IntegrationTestFixtureCore, + IClassFixture> + where TEntryPoint : class + where TWContext : DbContext + where TRContext : MongoDbContext +{ + protected IntegrationTestBase( + IntegrationTestFactory integrationTestFixture) : base(integrationTestFixture) + { + Fixture = integrationTestFixture; + } + + public new IntegrationTestFactory Fixture { get; } +} + +public abstract class IntegrationTestBase : + IntegrationTestFixtureCore, + IClassFixture> + where TEntryPoint : class + where TWContext : DbContext + where TRContext : MongoDbContext + where PContext : PersistMessageDbContext +{ + protected IntegrationTestBase( + IntegrationTestFactory integrationTestFixture) : base( + integrationTestFixture) + { + Fixture = integrationTestFixture; + } + + public new IntegrationTestFactory Fixture { get; } +} diff --git a/src/Services/Flight/tests/IntegrationTest/Fakes/FakeCreateFlightCommand.cs b/src/Services/Flight/tests/IntegrationTest/Fakes/FakeCreateFlightCommand.cs index 6f34b79..87a0291 100644 --- a/src/Services/Flight/tests/IntegrationTest/Fakes/FakeCreateFlightCommand.cs +++ b/src/Services/Flight/tests/IntegrationTest/Fakes/FakeCreateFlightCommand.cs @@ -1,6 +1,6 @@ using AutoBogus; using BuildingBlocks.IdsGenerator; -using Flight.Flights.Features.CreateFlight; +using Flight.Flights.Enums; using Flight.Flights.Features.CreateFlight.Commands.V1; namespace Integration.Test.Fakes; @@ -13,6 +13,7 @@ public sealed class FakeCreateFlightCommand : AutoFaker RuleFor(r => r.FlightNumber, r => r.Random.String()); RuleFor(r => r.DepartureAirportId, _ => 1); RuleFor(r => r.ArriveAirportId, _ => 2); + RuleFor(r => r.Status, _ => FlightStatus.Flying); RuleFor(r => r.AircraftId, _ => 1); } }