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();
}
}