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

Database Setup Rewrite #197

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
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
2 changes: 1 addition & 1 deletion LeaderboardBackend.Test/Categories.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public async Task OneTimeSetUp()
_factory = new TestApiFactory();
_apiClient = _factory.CreateTestApiClient();

_factory.ResetDatabase();
await _factory.ResetDatabase();
_jwt = (await _apiClient.LoginAdminUser()).Token;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ public void Init()
}

[TearDown]
public void TearDown()
public async Task TearDown()
{
_factory.ResetDatabase();
await _factory.ResetDatabase();
_scope.Dispose();
}

Expand Down
4 changes: 2 additions & 2 deletions LeaderboardBackend.Test/Features/Users/LoginTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ public void Init()
}

[TearDown]
public void TearDown()
public async Task TearDown()
{
_factory.ResetDatabase();
await _factory.ResetDatabase();
_scope.Dispose();
}

Expand Down
4 changes: 2 additions & 2 deletions LeaderboardBackend.Test/Features/Users/SendRecoveryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ public void Init()
}

[TearDown]
public void TearDown()
public async Task TearDown()
{
_factory.ResetDatabase();
await _factory.ResetDatabase();
_scope.Dispose();
}

Expand Down
63 changes: 1 addition & 62 deletions LeaderboardBackend.Test/Fixtures/PostgresDatabaseFixture.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
using System;
using System.Threading.Tasks;
using LeaderboardBackend.Test.Fixtures;
using Npgsql;
using NUnit.Framework;
using Testcontainers.PostgreSql;
Expand All @@ -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()
Expand All @@ -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.");
}
}
}
1 change: 1 addition & 0 deletions LeaderboardBackend.Test/LeaderboardBackend.Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Respawn" Version="6.1.0" />
<PackageReference Include="Testcontainers" Version="3.4.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="3.4.0" />
</ItemGroup>
Expand Down
2 changes: 1 addition & 1 deletion LeaderboardBackend.Test/Leaderboards.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public async Task OneTimeSetUp()
_factory = new TestApiFactory();
_apiClient = _factory.CreateTestApiClient();

_factory.ResetDatabase();
await _factory.ResetDatabase();
_jwt = (await _apiClient.LoginAdminUser()).Token;
}

Expand Down
2 changes: 1 addition & 1 deletion LeaderboardBackend.Test/Runs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public void OneTimeSetUp()
[SetUp]
public async Task SetUp()
{
_factory.ResetDatabase();
await _factory.ResetDatabase();

_jwt = (await _apiClient.LoginAdminUser()).Token;

Expand Down
104 changes: 63 additions & 41 deletions LeaderboardBackend.Test/TestApi/TestApiFactory.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<Program>
{
private static bool _migrated = false;
private static bool _seeded = false;
private readonly Mock<ISmtpClient> _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<ApplicationContextConfig>(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<ISmtpClient>(_ => new Mock<ISmtpClient>().Object));
services.Replace(ServiceDescriptor.Transient(_ => _mock.Object));

using IServiceScope scope = services.BuildServiceProvider().CreateScope();
ApplicationContext dbContext =
Expand All @@ -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;
}
}

/// <summary>
/// Deletes and recreates the database
/// </summary>
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();
}
}
42 changes: 29 additions & 13 deletions LeaderboardBackend/Models/Entities/ApplicationContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserRole>();
}

public ApplicationContext(DbContextOptions<ApplicationContext> options)
: base(options) { }

Expand All @@ -25,17 +17,41 @@ public ApplicationContext(DbContextOptions<ApplicationContext> options)
public DbSet<Run> Runs { get; set; } = null!;
public DbSet<User> Users { get; set; } = null!;

public void MigrateDatabase()
{
Database.Migrate();
NpgsqlConnection connection = (NpgsqlConnection)Database.GetDbConnection();
connection.Open();

try
{
connection.ReloadTypes();
}
finally
{
connection.Close();
}
}

/// <summary>
/// Migrates the database and reloads Npgsql types
/// </summary>
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)
Expand Down
Loading
Loading