diff --git a/LeaderboardBackend.Test/Categories.cs b/LeaderboardBackend.Test/Categories.cs index bb849d2a..adc2da3a 100644 --- a/LeaderboardBackend.Test/Categories.cs +++ b/LeaderboardBackend.Test/Categories.cs @@ -22,7 +22,7 @@ public async Task OneTimeSetUp() _factory = new TestApiFactory(); _apiClient = _factory.CreateTestApiClient(); - _factory.ResetDatabase(); + await _factory.ResetDatabase(); _jwt = (await _apiClient.LoginAdminUser()).Token; } diff --git a/LeaderboardBackend.Test/Features/Users/ConfirmAccountTests.cs b/LeaderboardBackend.Test/Features/Users/ConfirmAccountTests.cs index 7255e649..c9632663 100644 --- a/LeaderboardBackend.Test/Features/Users/ConfirmAccountTests.cs +++ b/LeaderboardBackend.Test/Features/Users/ConfirmAccountTests.cs @@ -35,9 +35,9 @@ public void Init() } [TearDown] - public void TearDown() + public async Task TearDown() { - _factory.ResetDatabase(); + await _factory.ResetDatabase(); _scope.Dispose(); } diff --git a/LeaderboardBackend.Test/Features/Users/LoginTests.cs b/LeaderboardBackend.Test/Features/Users/LoginTests.cs index 92f8de41..5af1aa66 100644 --- a/LeaderboardBackend.Test/Features/Users/LoginTests.cs +++ b/LeaderboardBackend.Test/Features/Users/LoginTests.cs @@ -20,9 +20,9 @@ namespace LeaderboardBackend.Test.Features.Users; public class LoginTests : IntegrationTestsBase { [OneTimeSetUp] - public void Init() + public async Task Init() { - _factory.ResetDatabase(); + await _factory.ResetDatabase(); // TODO: Swap to creating users via the UserService instead of calling the DB, once // it has the ability to change a user's roles. diff --git a/LeaderboardBackend.Test/Features/Users/SendConfirmationTests.cs b/LeaderboardBackend.Test/Features/Users/SendConfirmationTests.cs index 44b434d2..28693137 100644 --- a/LeaderboardBackend.Test/Features/Users/SendConfirmationTests.cs +++ b/LeaderboardBackend.Test/Features/Users/SendConfirmationTests.cs @@ -29,9 +29,9 @@ public void Init() } [TearDown] - public void TearDown() + public async Task TearDown() { - _factory.ResetDatabase(); + await _factory.ResetDatabase(); _scope.Dispose(); } diff --git a/LeaderboardBackend.Test/Features/Users/SendRecoveryTests.cs b/LeaderboardBackend.Test/Features/Users/SendRecoveryTests.cs index e722ad57..ce87d68a 100644 --- a/LeaderboardBackend.Test/Features/Users/SendRecoveryTests.cs +++ b/LeaderboardBackend.Test/Features/Users/SendRecoveryTests.cs @@ -28,9 +28,9 @@ public void Init() } [TearDown] - public void TearDown() + public async Task TearDown() { - _factory.ResetDatabase(); + await _factory.ResetDatabase(); _scope.Dispose(); } diff --git a/LeaderboardBackend.Test/Fixtures/PostgresDatabaseFixture.cs b/LeaderboardBackend.Test/Fixtures/PostgresDatabaseFixture.cs index 96e3ebf6..57e7a640 100644 --- a/LeaderboardBackend.Test/Fixtures/PostgresDatabaseFixture.cs +++ b/LeaderboardBackend.Test/Fixtures/PostgresDatabaseFixture.cs @@ -1,6 +1,4 @@ -using System; using System.Threading.Tasks; -using LeaderboardBackend.Test.Fixtures; using Npgsql; using NUnit.Framework; using Testcontainers.PostgreSql; @@ -9,15 +7,13 @@ // It has no namespace on purpose, so that the fixture applies to all tests in this assembly [SetUpFixture] // https://docs.nunit.org/articles/nunit/writing-tests/attributes/setupfixture.html -internal class PostgresDatabaseFixture +public class PostgresDatabaseFixture { public static PostgreSqlContainer? PostgresContainer { get; private set; } public static string? Database { get; private set; } public static string? Username { get; private set; } public static string? Password { get; private set; } public static int Port { get; private set; } - public static bool HasCreatedTemplate { get; private set; } = false; - private static string TemplateDatabase => Database! + "_template"; [OneTimeSetUp] public static async Task OneTimeSetup() @@ -44,61 +40,4 @@ public static async Task OneTimeTearDown() await PostgresContainer.DisposeAsync(); } - - public static void CreateTemplateFromCurrentDb() - { - ThrowIfNotInitialized(); - - NpgsqlConnection.ClearAllPools(); // can't drop a DB if connections remain open - using NpgsqlDataSource conn = CreateConnectionToTemplate(); - conn.CreateCommand( - @$" - DROP DATABASE IF EXISTS {TemplateDatabase}; - CREATE DATABASE {TemplateDatabase} - WITH TEMPLATE {Database} - OWNER '{Username}'; - " - ) - .ExecuteNonQuery(); - HasCreatedTemplate = true; - } - - // It is faster to recreate the db from an already seeded template - // compared to dropping the db and recreating it from scratch - public static void ResetDatabaseToTemplate() - { - ThrowIfNotInitialized(); - if (!HasCreatedTemplate) - { - throw new InvalidOperationException("Database template has not been created."); - } - - NpgsqlConnection.ClearAllPools(); // can't drop a DB if connections remain open - using NpgsqlDataSource conn = CreateConnectionToTemplate(); - conn.CreateCommand( - @$" - DROP DATABASE IF EXISTS {Database}; - CREATE DATABASE {Database} - WITH TEMPLATE {TemplateDatabase} - OWNER '{Username}'; - " - ) - .ExecuteNonQuery(); - } - - private static NpgsqlDataSource CreateConnectionToTemplate() - { - ThrowIfNotInitialized(); - NpgsqlConnectionStringBuilder connStrBuilder = - new(PostgresContainer!.GetConnectionString()) { Database = "template1" }; - return NpgsqlDataSource.Create(connStrBuilder); - } - - private static void ThrowIfNotInitialized() - { - if (PostgresContainer is null) - { - throw new InvalidOperationException("Postgres container is not initialized."); - } - } } diff --git a/LeaderboardBackend.Test/LeaderboardBackend.Test.csproj b/LeaderboardBackend.Test/LeaderboardBackend.Test.csproj index 3cc526c2..eaab3c40 100644 --- a/LeaderboardBackend.Test/LeaderboardBackend.Test.csproj +++ b/LeaderboardBackend.Test/LeaderboardBackend.Test.csproj @@ -25,6 +25,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/LeaderboardBackend.Test/Leaderboards.cs b/LeaderboardBackend.Test/Leaderboards.cs index f8f1621b..cc439365 100644 --- a/LeaderboardBackend.Test/Leaderboards.cs +++ b/LeaderboardBackend.Test/Leaderboards.cs @@ -30,7 +30,7 @@ public async Task OneTimeSetUp() _factory = new TestApiFactory(); _apiClient = _factory.CreateTestApiClient(); - _factory.ResetDatabase(); + await _factory.ResetDatabase(); _jwt = (await _apiClient.LoginAdminUser()).Token; } diff --git a/LeaderboardBackend.Test/Runs.cs b/LeaderboardBackend.Test/Runs.cs index 4a53123f..373c05df 100644 --- a/LeaderboardBackend.Test/Runs.cs +++ b/LeaderboardBackend.Test/Runs.cs @@ -28,7 +28,7 @@ public void OneTimeSetUp() [SetUp] public async Task SetUp() { - _factory.ResetDatabase(); + await _factory.ResetDatabase(); _jwt = (await _apiClient.LoginAdminUser()).Token; diff --git a/LeaderboardBackend.Test/TestApi/TestApiFactory.cs b/LeaderboardBackend.Test/TestApi/TestApiFactory.cs index 832ac50c..6eaec1fa 100644 --- a/LeaderboardBackend.Test/TestApi/TestApiFactory.cs +++ b/LeaderboardBackend.Test/TestApi/TestApiFactory.cs @@ -1,5 +1,6 @@ using System; using System.Net.Http; +using System.Threading.Tasks; using LeaderboardBackend.Models.Entities; using LeaderboardBackend.Test.Lib; using MailKit.Net.Smtp; @@ -9,40 +10,38 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Moq; +using Npgsql; +using Respawn; +using Respawn.Graph; using BCryptNet = BCrypt.Net.BCrypt; namespace LeaderboardBackend.Test.TestApi; public class TestApiFactory : WebApplicationFactory { + private static bool _migrated = false; + private static bool _seeded = false; + private readonly Mock _mock = new(); protected override void ConfigureWebHost(IWebHostBuilder builder) { // Set the environment for the run to Staging builder.UseEnvironment(Environments.Staging); - base.ConfigureWebHost(builder); - - builder.ConfigureServices(services => + if (PostgresDatabaseFixture.PostgresContainer is null) { - if (PostgresDatabaseFixture.PostgresContainer is null) - { - throw new InvalidOperationException("Postgres container is not initialized."); - } + throw new InvalidOperationException("Postgres container is not initialized."); + } - services.Configure(conf => - { - conf.Pg = new PostgresConfig - { - Db = PostgresDatabaseFixture.Database!, - Port = (ushort)PostgresDatabaseFixture.Port, - Host = PostgresDatabaseFixture.PostgresContainer.Hostname, - User = PostgresDatabaseFixture.Username!, - Password = PostgresDatabaseFixture.Password! - }; - }); + Environment.SetEnvironmentVariable("ApplicationContext__PG__DB", PostgresDatabaseFixture.Database); + Environment.SetEnvironmentVariable("ApplicationContext__PG__PORT", PostgresDatabaseFixture.Port.ToString()); + Environment.SetEnvironmentVariable("ApplicationContext__PG__HOST", PostgresDatabaseFixture.PostgresContainer!.Hostname); + Environment.SetEnvironmentVariable("ApplicationContext__PG__USER", PostgresDatabaseFixture.Username); + Environment.SetEnvironmentVariable("ApplicationContext__PG__PASSWORD", PostgresDatabaseFixture.Password); + builder.ConfigureServices(services => + { // mock SMTP client - services.Replace(ServiceDescriptor.Transient(_ => new Mock().Object)); + services.Replace(ServiceDescriptor.Transient(_ => _mock.Object)); using IServiceScope scope = services.BuildServiceProvider().CreateScope(); ApplicationContext dbContext = @@ -64,42 +63,65 @@ public void InitializeDatabase() InitializeDatabase(dbContext); } - private static void InitializeDatabase(ApplicationContext dbContext) + private void InitializeDatabase(ApplicationContext dbContext) { - if (!PostgresDatabaseFixture.HasCreatedTemplate) + if (!_migrated) { dbContext.MigrateDatabase(); - Seed(dbContext); - PostgresDatabaseFixture.CreateTemplateFromCurrentDb(); + _migrated = true; } - } - private static void Seed(ApplicationContext dbContext) + Seed(dbContext); + } + private void Seed(ApplicationContext dbContext) { - Leaderboard leaderboard = - new() { Name = "Mario Goes to Jail", Slug = "mario-goes-to-jail" }; + if (!_seeded) + { + Leaderboard leaderboard = + new() { Name = "Mario Goes to Jail", Slug = "mario-goes-to-jail" }; - User admin = - new() - { - Id = TestInitCommonFields.Admin.Id, - Username = TestInitCommonFields.Admin.Username, - Email = TestInitCommonFields.Admin.Email, - Password = BCryptNet.EnhancedHashPassword(TestInitCommonFields.Admin.Password), - Role = UserRole.Administrator, - }; + User admin = + new() + { + Id = TestInitCommonFields.Admin.Id, + Username = TestInitCommonFields.Admin.Username, + Email = TestInitCommonFields.Admin.Email, + Password = BCryptNet.EnhancedHashPassword(TestInitCommonFields.Admin.Password), + Role = UserRole.Administrator, + }; - dbContext.Add(admin); - dbContext.Add(leaderboard); + dbContext.Add(admin); + dbContext.Add(leaderboard); - dbContext.SaveChanges(); + dbContext.SaveChanges(); + _seeded = true; + } } /// /// Deletes and recreates the database /// - public void ResetDatabase() + public async Task ResetDatabase() { - PostgresDatabaseFixture.ResetDatabaseToTemplate(); + using NpgsqlConnection conn = new(PostgresDatabaseFixture.PostgresContainer!.GetConnectionString()); + await conn.OpenAsync(); + + Respawner respawner = await Respawner.CreateAsync(conn, new RespawnerOptions + { + TablesToInclude = new Table[] + { + "users", + "categories", + "leaderboards", + "account_confirmations", + "account_recoveries", + "runs" + }, + DbAdapter = DbAdapter.Postgres + }); + + await respawner.ResetAsync(conn); + _seeded = false; + InitializeDatabase(); } } diff --git a/LeaderboardBackend/Models/Entities/ApplicationContext.cs b/LeaderboardBackend/Models/Entities/ApplicationContext.cs index 29cea966..61d7b627 100644 --- a/LeaderboardBackend/Models/Entities/ApplicationContext.cs +++ b/LeaderboardBackend/Models/Entities/ApplicationContext.cs @@ -7,14 +7,6 @@ namespace LeaderboardBackend.Models.Entities; public class ApplicationContext : DbContext { public const string CASE_INSENSITIVE_COLLATION = "case_insensitive"; - - [Obsolete] - static ApplicationContext() - { - // GlobalTypeMapper is obsolete but the new way (DataSource) is a pain to work with - NpgsqlConnection.GlobalTypeMapper.MapEnum(); - } - public ApplicationContext(DbContextOptions options) : base(options) { } @@ -25,17 +17,41 @@ public ApplicationContext(DbContextOptions options) public DbSet Runs { get; set; } = null!; public DbSet Users { get; set; } = null!; + public void MigrateDatabase() + { + Database.Migrate(); + NpgsqlConnection connection = (NpgsqlConnection)Database.GetDbConnection(); + connection.Open(); + + try + { + connection.ReloadTypes(); + } + finally + { + connection.Close(); + } + } + /// /// Migrates the database and reloads Npgsql types /// - public void MigrateDatabase() + public async Task MigrateDatabaseAsync() { - Database.Migrate(); + await Database.MigrateAsync(); // when new extensions have been enabled by migrations, Npgsql's type cache must be refreshed - Database.OpenConnection(); - ((NpgsqlConnection)Database.GetDbConnection()).ReloadTypes(); - Database.CloseConnection(); + NpgsqlConnection connection = (NpgsqlConnection)Database.GetDbConnection(); + await connection.OpenAsync(); + + try + { + await connection.ReloadTypesAsync(); + } + finally + { + await connection.CloseAsync(); + } } protected override void OnModelCreating(ModelBuilder modelBuilder) diff --git a/LeaderboardBackend/Program.cs b/LeaderboardBackend/Program.cs index dfc5199a..8b07798e 100644 --- a/LeaderboardBackend/Program.cs +++ b/LeaderboardBackend/Program.cs @@ -60,41 +60,31 @@ .ValidateDataAnnotationsRecursively() .ValidateOnStart(); -builder.Services.AddDbContext( - (services, opt) => - { - ApplicationContextConfig appConfig = services - .GetRequiredService>() - .Value; - if (appConfig.Pg is not null) - { - PostgresConfig db = appConfig.Pg; - NpgsqlConnectionStringBuilder connectionBuilder = - new() - { - Host = db.Host, - Username = db.User, - Password = db.Password, - Database = db.Db, - IncludeErrorDetail = true, - }; - - if (db.Port is not null) - { - connectionBuilder.Port = db.Port.Value; - } +PostgresConfig db = builder.Configuration.GetSection(ApplicationContextConfig.KEY).Get()!.Pg!; - opt.UseNpgsql(connectionBuilder.ConnectionString, o => o.UseNodaTime()); - opt.UseSnakeCaseNamingConvention(); - } - else - { - throw new UnreachableException( - "The database configuration is invalid but it was not caught by validation!" - ); - } - } -); +NpgsqlConnectionStringBuilder connectionBuilder = new() +{ + Host = db.Host, + Username = db.User, + Password = db.Password, + Database = db.Db, + IncludeErrorDetail = true, +}; + +if (db.Port is not null) +{ + connectionBuilder.Port = db.Port.Value; +} + +NpgsqlDataSourceBuilder dataSourceBuilder = new(connectionBuilder.ConnectionString); +dataSourceBuilder.UseNodaTime().MapEnum(); +NpgsqlDataSource dataSource = dataSourceBuilder.Build(); + +builder.Services.AddDbContext(opt => +{ + opt.UseNpgsql(dataSource, o => o.UseNodaTime()); + opt.UseSnakeCaseNamingConvention(); +}); // Add services to the container. builder.Services.AddScoped(); @@ -311,7 +301,7 @@ if (config.MigrateDb && app.Environment.IsDevelopment()) { // migration as part of the startup phase (dev env only) - context.MigrateDatabase(); + await context.MigrateDatabaseAsync(); } }