Skip to content

Commit

Permalink
feat: TestContainers 🚀
Browse files Browse the repository at this point in the history
  • Loading branch information
trulshj committed Jan 8, 2025
1 parent bd7b2f4 commit ba784f2
Show file tree
Hide file tree
Showing 24 changed files with 561 additions and 113 deletions.
4 changes: 4 additions & 0 deletions backend/Api/Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,9 @@
<_ContentIncludedByDefault Remove="Routes\obj\project.packagespec.json"/>
</ItemGroup>

<ItemGroup>
<None Include="appsettings.Test.json"/>
</ItemGroup>


</Project>
15 changes: 0 additions & 15 deletions backend/Api/AppExtensions/DatabaseExtensions.cs

This file was deleted.

22 changes: 11 additions & 11 deletions backend/Api/Program.cs
Original file line number Diff line number Diff line change
@@ -1,32 +1,26 @@
using System.Text.Json.Serialization;
using Api.AppExtensions;
using Api.Options;
using Infrastructure.DatabaseContext;
using Infrastructure;
using Infrastructure.Repositories;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.Identity.Web;
using Microsoft.OpenApi.Models;

var builder = WebApplication.CreateBuilder(args);
var connection = builder.Configuration.GetConnectionString("VibesDb");

if (string.IsNullOrEmpty(connection))
throw new InvalidOperationException("No connection string found");
builder.AddInfrastructure();

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(builder.Configuration);
builder.Services.AddAuthorization(opt => { opt.FallbackPolicy = opt.DefaultPolicy; });

builder.Services.AddDbContext<ApplicationContext>(options => options.UseSqlServer(connection));
builder.Services.AddMemoryCache();
builder.AddRepositories();

builder.Services.AddControllers()
.AddJsonOptions(options => { options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); });
builder.Services.AddEndpointsApiExplorer();


var adOptions = builder.Configuration.GetSection("AzureAd").Get<AzureAdOptions>();
if (adOptions == null) throw new InvalidOperationException("Required AzureAd options are missing");

Expand All @@ -40,8 +34,6 @@

var app = builder.Build();

app.ApplyMigrations();

app.UsePathBase("/v0");
app.MapControllers();

Expand All @@ -61,4 +53,12 @@

app.UseAuthorization();

await app.RunAsync();
await app.RunAsync();

namespace Api
{
// ReSharper disable once PartialTypeWithSinglePart
// ReSharper disable once UnusedType.Global
// ReSharper disable once ClassNeverInstantiated.Global
public partial class Program;
}
20 changes: 20 additions & 0 deletions backend/Api/appsettings.Local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"AzureAd": {
"DisableAuthAd": false,
"Instance": "https://login.microsoftonline.com/",
"ClientId": "7ef3a24e-7093-41dc-9163-9618415137fe",
"TenantId": "0f16d077-bd82-4a6c-b498-52741239205f",
"ApiScope": "api://7ef3a24e-7093-41dc-9163-9618415137fe/Vibes.ReadWrite"
},
"ConnectionStrings": {
"VibesDb": "Server=localhost;Database=AzureVibesDb;User Id=sa;Password=yourStrong(!)Password;;TrustServerCertificate=true"
},
"InfrastructureConfig": {
"EnableSensitiveDataLogging": true
},
"TestContainersConfig": {
"Enabled": true,
"RunMigrations": true,
"SeedDatabase": false
}
}
7 changes: 7 additions & 0 deletions backend/Api/appsettings.Test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"TestContainersConfig": {
"Enabled": true,
"RunMigrations": true,
"SeedDatabase": false
}
}
9 changes: 8 additions & 1 deletion backend/Infrastructure/DatabaseContext/ApplicationContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@
using Core.Weeks;
using Infrastructure.ValueConverters;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;

namespace Infrastructure.DatabaseContext;

public class ApplicationContext(DbContextOptions options) : DbContext(options)
public class ApplicationContext(IOptions<InfrastructureConfig> config) : DbContext
{
public DbSet<Consultant> Consultant { get; init; } = null!;
public DbSet<Competence> Competence { get; init; } = null!;
Expand All @@ -28,6 +29,12 @@ public class ApplicationContext(DbContextOptions options) : DbContext(options)
public DbSet<Staffing> Staffing { get; init; } = null!;
public DbSet<Agreement> Agreements { get; init; } = null!;

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer(config.Value.ConnectionString)
.EnableSensitiveDataLogging(config.Value
.EnableSensitiveDataLogging);
}

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
Expand Down
23 changes: 23 additions & 0 deletions backend/Infrastructure/DependencyInjection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Infrastructure.DatabaseContext;
using Infrastructure.TestContainers;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace Infrastructure;

public static class DependencyInjection
{
public static IHostApplicationBuilder AddInfrastructure(this IHostApplicationBuilder builder)
{
builder
.AddConfig<InfrastructureConfig>(out _)
.AddConfig<TestContainersConfig>(out var currentTestContainersConfig);

if (currentTestContainersConfig.Enabled)
builder.AddTestContainers();

builder.Services.AddDbContext<ApplicationContext>();

return builder;
}
}
3 changes: 3 additions & 0 deletions backend/Infrastructure/Infrastructure.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="7.0.10"/>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0"/>
<PackageReference Include="Scrutor" Version="5.0.1"/>
<PackageReference Include="Testcontainers" Version="4.1.0"/>
<PackageReference Include="Testcontainers.SqlEdge" Version="3.10.0"/>
</ItemGroup>


Expand Down
24 changes: 24 additions & 0 deletions backend/Infrastructure/InfrastructureConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace Infrastructure;

public sealed record InfrastructureConfig
{
public required string ConnectionString { get; set; }
public required bool EnableSensitiveDataLogging { get; set; }
}

internal static class ConfigExtensions
{
internal static IHostApplicationBuilder AddConfig<T>(this IHostApplicationBuilder builder, out T currentConfig)
where T : class
{
var name = typeof(T);
var configSection = builder.Configuration.GetSection(typeof(T).Name);
builder.Services.Configure<T>(configSection);
currentConfig = configSection.Get<T>()!;
return builder;
}
}
8 changes: 8 additions & 0 deletions backend/Infrastructure/TestContainers/TestContainersConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Infrastructure.TestContainers;

public record TestContainersConfig
{
public required bool Enabled { get; set; }
public required bool RunMigrations { get; set; }
public required bool SeedDatabase { get; set; }
}
82 changes: 82 additions & 0 deletions backend/Infrastructure/TestContainers/TestContainersFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using Infrastructure.DatabaseContext;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Testcontainers.SqlEdge;

namespace Infrastructure.TestContainers;

public class TestContainersFactory(TestContainersConfig config, ILogger<TestContainersFactory> logger)
{
private const string DbPassword = "test123!";
private const int DbHostPort = 14333;

public static readonly string DefaultConnectionString =
$"Server=127.0.0.1,{DbHostPort};Database=master;User Id=sa;Password={DbPassword};TrustServerCertificate=True";

private SqlEdgeContainer? _sqlEdgeContainer;

public string? CurrentConnectionString => _sqlEdgeContainer?.GetConnectionString();

public async Task Start(CancellationToken cancellationToken = default, Overrides? overrides = null)
{
try
{
if (!config.Enabled) return;

var dbHostPort = overrides?.DbHostPortOverride ?? DbHostPort;
var dbContainerName = overrides?.DbContainerNameOverride ?? "testcontainers-api-db";

logger.LogInformation("Starting TestContainers");

_sqlEdgeContainer = new SqlEdgeBuilder()
.WithName(dbContainerName)
.WithReuse(true)
.WithPassword(DbPassword)
.WithPortBinding(dbHostPort, 1433)
.Build();

await _sqlEdgeContainer.StartAsync(cancellationToken);

var options = Options.Create(new InfrastructureConfig
{
ConnectionString = _sqlEdgeContainer.GetConnectionString(),
EnableSensitiveDataLogging = true
});

await using var context = new ApplicationContext(options);

if (config.RunMigrations)
await MigrateDatabase(context, cancellationToken);

if (config.SeedDatabase) logger.LogInformation("Seeding database with test data");
// TODO: DataBuilder
}
catch (Exception e)
{
logger.LogError(e, "Error while starting TestContainers");
}
}

public Task Stop(CancellationToken cancellationToken = default)
{
var stopTask = _sqlEdgeContainer?.StopAsync(cancellationToken) ?? Task.CompletedTask;
return stopTask;
}

public record Overrides(string? DbContainerNameOverride, int? DbHostPortOverride);

private async Task MigrateDatabase(DbContext context, CancellationToken cancellationToken)
{
var retries = 0;
while (!await context.Database.CanConnectAsync(cancellationToken) && retries < 10)
{
retries++;
logger.LogInformation("Attempt #{AttemptNumber} to connect to DB failed, retrying in 1 second.", retries);
await Task.Delay(1000, cancellationToken);
}

logger.LogInformation("Running database migrations");
await context.Database.MigrateAsync(cancellationToken);
}
}
26 changes: 26 additions & 0 deletions backend/Infrastructure/TestContainers/TestContainersHostBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace Infrastructure.TestContainers;

public static class TestContainersHostBuilder
{
public static void AddTestContainers(this IHostApplicationBuilder builder)
{
if (!builder.Environment.IsValidTestContainersEnvironment())
throw new InvalidOperationException(
$"{nameof(AddTestContainers)} should only be called in dev environments. Current environment: {builder.Environment.EnvironmentName}");

builder.Services.AddHostedService<TestContainersService>();

builder.Services.Configure<InfrastructureConfig>(opts =>
{
opts.ConnectionString = TestContainersFactory.DefaultConnectionString;
});
}

private static bool IsValidTestContainersEnvironment(this IHostEnvironment environment)
{
return environment.IsDevelopment() || environment.EnvironmentName == "Local";
}
}
43 changes: 43 additions & 0 deletions backend/Infrastructure/TestContainers/TestContainersService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Infrastructure.TestContainers;

public class TestContainersService(IServiceProvider serviceProvider) : IHostedService
{
private TestContainersFactory? _testContainersFactory;

public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = serviceProvider.CreateScope();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<TestContainersService>>();

try
{
logger.LogInformation("Running TestContainersService");

var config = scope.ServiceProvider.GetRequiredService<IOptions<TestContainersConfig>>().Value;
if (config.Enabled)
{
var testContainersLogger = scope.ServiceProvider.GetRequiredService<ILogger<TestContainersFactory>>();
_testContainersFactory = new TestContainersFactory(config, testContainersLogger);

await _testContainersFactory.Start(cancellationToken);
}
}
catch (Exception e)
{
logger.LogError(e, "Failed to start test containers service");
throw;
}

logger.LogInformation("Finished running TestContainersService");
}

public Task StopAsync(CancellationToken cancellationToken)
{
return _testContainersFactory?.Stop(cancellationToken) ?? Task.CompletedTask;
}
}
Loading

0 comments on commit ba784f2

Please sign in to comment.