Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: TestContainers 🚀 #578

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be gitignored?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops

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
Loading