diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml
index 461ba8e8..cfc9f7a3 100644
--- a/.github/workflows/test-backend.yml
+++ b/.github/workflows/test-backend.yml
@@ -13,11 +13,9 @@ jobs:
uses: actions/setup-dotnet@v1
with:
dotnet-version: '7.0.x'
- - name: Create .env file
- run: cp example.env .env
- - name: Spin up containers
- run: docker compose up -d
- - name: Run Tests on In Memory Database
- run: ApplicationContext__UseInMemoryDb=true dotnet test LeaderboardBackend.Test
- - name: Run Tests on Test Postgres Database
- run: ApplicationContext__UseInMemoryDb=false dotnet test LeaderboardBackend.Test
+ - name: Restore NuGet packages
+ run: dotnet restore
+ - name: Build
+ run: dotnet build --no-restore
+ - name: Run tests
+ run: dotnet test LeaderboardBackend.Test --no-restore
diff --git a/LeaderboardBackend.Jobs/LeaderboardBackend.Jobs.csproj b/LeaderboardBackend.Jobs/LeaderboardBackend.Jobs.csproj
index 73850be8..7ad9a1d2 100644
--- a/LeaderboardBackend.Jobs/LeaderboardBackend.Jobs.csproj
+++ b/LeaderboardBackend.Jobs/LeaderboardBackend.Jobs.csproj
@@ -13,7 +13,7 @@
-
+
Always
diff --git a/LeaderboardBackend.Test/Bans.cs b/LeaderboardBackend.Test/Bans.cs
index 1ba15c8d..0fd217e4 100644
--- a/LeaderboardBackend.Test/Bans.cs
+++ b/LeaderboardBackend.Test/Bans.cs
@@ -22,11 +22,14 @@ internal class Bans
private static User s_modUser = null!;
private static User s_normalUser = null!;
- [SetUp]
- public static async Task SetUp()
+ [OneTimeSetUp]
+ public async Task OneTimeSetup()
{
- s_factory = new TestApiFactory();
+ s_factory = new();
s_apiClient = s_factory.CreateTestApiClient();
+
+ s_factory.ResetDatabase();
+
s_adminJwt = (await s_apiClient.LoginAdminUser()).Token;
// Set up users and leaderboard
@@ -61,6 +64,12 @@ await s_apiClient.Post(
s_modJwt = (await s_apiClient.LoginUser("mod@email.com", "Passw0rd!")).Token;
}
+ [OneTimeTearDown]
+ public void OneTimeTeardown()
+ {
+ s_factory.Dispose();
+ }
+
[Test]
public static async Task CreateSiteBan_Ok()
{
diff --git a/LeaderboardBackend.Test/Categories.cs b/LeaderboardBackend.Test/Categories.cs
index 0793b457..cc5735fc 100644
--- a/LeaderboardBackend.Test/Categories.cs
+++ b/LeaderboardBackend.Test/Categories.cs
@@ -17,13 +17,21 @@ internal class Categories
private static string? s_jwt;
[OneTimeSetUp]
- public static async Task SetUp()
+ public async Task OneTimeSetUp()
{
s_factory = new TestApiFactory();
s_apiClient = s_factory.CreateTestApiClient();
+
+ s_factory.ResetDatabase();
s_jwt = (await s_apiClient.LoginAdminUser()).Token;
}
+ [OneTimeTearDown]
+ public void OneTimeTearDown()
+ {
+ s_factory.Dispose();
+ }
+
[Test]
public static void GetCategory_Unauthorized()
{
diff --git a/LeaderboardBackend.Test/Fixtures/PostgresDatabaseFixture.cs b/LeaderboardBackend.Test/Fixtures/PostgresDatabaseFixture.cs
new file mode 100644
index 00000000..6fc97180
--- /dev/null
+++ b/LeaderboardBackend.Test/Fixtures/PostgresDatabaseFixture.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Threading.Tasks;
+using LeaderboardBackend.Test.Fixtures;
+using Npgsql;
+using NUnit.Framework;
+using Testcontainers.PostgreSql;
+
+// Fixtures apply to all tests in its namespace
+// 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 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()
+ {
+ if (TestConfig.DatabaseBackend != DatabaseBackend.TestContainer)
+ {
+ return;
+ }
+
+ PostgresContainer = new PostgreSqlBuilder()
+ .WithTmpfsMount("/var/lib/postgresql/data") // db files in-memory
+ .Build();
+ await PostgresContainer.StartAsync();
+
+ NpgsqlConnectionStringBuilder connStrBuilder = new(PostgresContainer.GetConnectionString());
+ Username = connStrBuilder.Username!;
+ Password = connStrBuilder.Password!;
+ Database = connStrBuilder.Database!;
+ Port = connStrBuilder.Port;
+ }
+
+ [OneTimeTearDown]
+ public static async Task OneTimeTearDown()
+ {
+ if (PostgresContainer is null)
+ {
+ return;
+ }
+
+ 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/Fixtures/TestConfig.cs b/LeaderboardBackend.Test/Fixtures/TestConfig.cs
new file mode 100644
index 00000000..e766321d
--- /dev/null
+++ b/LeaderboardBackend.Test/Fixtures/TestConfig.cs
@@ -0,0 +1,23 @@
+using System;
+
+namespace LeaderboardBackend.Test.Fixtures;
+
+internal static class TestConfig
+{
+ public static DatabaseBackend DatabaseBackend { get; private set; } = DatabaseBackend.TestContainer;
+
+ static TestConfig()
+ {
+ string? backendVar = Environment.GetEnvironmentVariable("INTEGRATION_TESTS_DB_BACKEND");
+ if (Enum.TryParse(backendVar, out DatabaseBackend dbBackend))
+ {
+ DatabaseBackend = dbBackend;
+ }
+ }
+}
+
+internal enum DatabaseBackend
+{
+ InMemory,
+ TestContainer,
+}
diff --git a/LeaderboardBackend.Test/Judgements.cs b/LeaderboardBackend.Test/Judgements.cs
index 3c30327c..0c2e881a 100644
--- a/LeaderboardBackend.Test/Judgements.cs
+++ b/LeaderboardBackend.Test/Judgements.cs
@@ -24,12 +24,14 @@ internal class Judgements
private const string VALID_PASSWORD = "c00l_pAssword";
private const string VALID_EMAIL = "test@email.com";
- [SetUp]
- public static async Task SetUp()
+ [OneTimeSetUp]
+ public async Task OneTimeSetup()
{
s_factory = new TestApiFactory();
s_apiClient = s_factory.CreateTestApiClient();
+ s_factory.ResetDatabase();
+
// Set up a default Leaderboard and a mod user for that leaderboard to use as the Jwt for
// tests
string adminJwt = (await s_apiClient.LoginAdminUser()).Token;
@@ -77,6 +79,12 @@ public static async Task SetUp()
s_jwt = (await s_apiClient.LoginUser(VALID_EMAIL, VALID_PASSWORD)).Token;
}
+ [OneTimeTearDown]
+ public void OneTimeTearDown()
+ {
+ s_factory.Dispose();
+ }
+
[Test]
public async Task CreateJudgement_OK()
{
diff --git a/LeaderboardBackend.Test/LeaderboardBackend.Test.csproj b/LeaderboardBackend.Test/LeaderboardBackend.Test.csproj
index 1a68fee9..37350fbd 100644
--- a/LeaderboardBackend.Test/LeaderboardBackend.Test.csproj
+++ b/LeaderboardBackend.Test/LeaderboardBackend.Test.csproj
@@ -16,6 +16,8 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
@@ -23,7 +25,7 @@
-
+
Always
diff --git a/LeaderboardBackend.Test/Leaderboards.cs b/LeaderboardBackend.Test/Leaderboards.cs
index 91c96823..7dd669c9 100644
--- a/LeaderboardBackend.Test/Leaderboards.cs
+++ b/LeaderboardBackend.Test/Leaderboards.cs
@@ -19,19 +19,27 @@ internal class Leaderboards
private static TestApiFactory s_factory = null!;
private static string? s_jwt;
- [SetUp]
- public static async Task SetUp()
+ [OneTimeSetUp]
+ public async Task OneTimeSetUp()
{
s_factory = new TestApiFactory();
s_apiClient = s_factory.CreateTestApiClient();
+
+ s_factory.ResetDatabase();
s_jwt = (await s_apiClient.LoginAdminUser()).Token;
}
+ [OneTimeTearDown]
+ public void OneTimeTearDown()
+ {
+ s_factory.Dispose();
+ }
+
[Test]
public static void GetLeaderboard_NotFound()
{
RequestFailureException e = Assert.ThrowsAsync(async () =>
- await s_apiClient.Get($"/api/leaderboards/2", new()))!;
+ await s_apiClient.Get($"/api/leaderboards/{long.MaxValue}", new()))!;
Assert.AreEqual(HttpStatusCode.NotFound, e.Response.StatusCode);
}
diff --git a/LeaderboardBackend.Test/Modships.cs b/LeaderboardBackend.Test/Modships.cs
index bb938c1b..4d7dff01 100644
--- a/LeaderboardBackend.Test/Modships.cs
+++ b/LeaderboardBackend.Test/Modships.cs
@@ -18,14 +18,27 @@ internal class Modships
private static TestApiFactory s_factory = null!;
private static string s_jwt = null!;
- [SetUp]
- public static async Task SetUp()
+ [OneTimeSetUp]
+ public async Task OneTimeSetUp()
{
s_factory = new TestApiFactory();
s_apiClient = s_factory.CreateTestApiClient();
+
s_jwt = (await s_apiClient.LoginAdminUser()).Token;
}
+ [OneTimeTearDown]
+ public void OneTimeTearDown()
+ {
+ s_factory.Dispose();
+ }
+
+ [SetUp]
+ public void SetUp()
+ {
+ s_factory.ResetDatabase();
+ }
+
[Test]
public static async Task CreateModship_OK()
{
diff --git a/LeaderboardBackend.Test/Runs.cs b/LeaderboardBackend.Test/Runs.cs
index c2aa45a0..0eaf7ced 100644
--- a/LeaderboardBackend.Test/Runs.cs
+++ b/LeaderboardBackend.Test/Runs.cs
@@ -19,11 +19,18 @@ internal class Runs
private static string s_jwt = null!;
private static long s_categoryId;
- [SetUp]
- public static async Task SetUp()
+ [OneTimeSetUp]
+ public void OneTimeSetUp()
{
s_factory = new TestApiFactory();
s_apiClient = s_factory.CreateTestApiClient();
+ }
+
+ [SetUp]
+ public async Task SetUp()
+ {
+ s_factory.ResetDatabase();
+
s_jwt = (await s_apiClient.LoginAdminUser()).Token;
Leaderboard createdLeaderboard = await s_apiClient.Post(
diff --git a/LeaderboardBackend.Test/TestApi/TestApiFactory.cs b/LeaderboardBackend.Test/TestApi/TestApiFactory.cs
index 42e1619b..ca62ce5c 100644
--- a/LeaderboardBackend.Test/TestApi/TestApiFactory.cs
+++ b/LeaderboardBackend.Test/TestApi/TestApiFactory.cs
@@ -1,5 +1,7 @@
+using System;
using System.Net.Http;
using LeaderboardBackend.Models.Entities;
+using LeaderboardBackend.Test.Fixtures;
using LeaderboardBackend.Test.Lib;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
@@ -19,36 +21,55 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
base.ConfigureWebHost(builder);
- builder.ConfigureServices(services =>
- {
- int testPort = DotNetEnv.Env.GetInt("POSTGRES_TEST_PORT", -1);
- if (testPort >= 0)
+ builder.ConfigureServices(services =>
+ {
+ if (TestConfig.DatabaseBackend == DatabaseBackend.TestContainer)
{
+ if (PostgresDatabaseFixture.PostgresContainer is null)
+ {
+ throw new InvalidOperationException("Postgres container is not initialized.");
+ }
+
services.Configure(conf =>
{
- if (conf.Pg is not null)
+ conf.UseInMemoryDb = false;
+ conf.Pg = new PostgresConfig
{
- conf.Pg.Port = (ushort)testPort;
- }
+ Db = PostgresDatabaseFixture.Database!,
+ Port = (ushort)PostgresDatabaseFixture.Port,
+ Host = PostgresDatabaseFixture.PostgresContainer.Hostname,
+ User = PostgresDatabaseFixture.Username!,
+ Password = PostgresDatabaseFixture.Password!
+ };
});
}
-
- // Reset the database on every test run
- using ServiceProvider scope = services.BuildServiceProvider();
- ApplicationContext dbContext = scope.GetRequiredService();
- dbContext.Database.EnsureDeleted();
-
- if (dbContext.Database.IsInMemory())
- {
- dbContext.Database.EnsureCreated();
- }
else
{
- dbContext.Database.Migrate();
+ services.Configure(conf =>
+ {
+ conf.UseInMemoryDb = true;
+ });
}
- Seed(dbContext);
- });
+ using IServiceScope scope = services.BuildServiceProvider().CreateScope();
+ ApplicationContext dbContext = scope.ServiceProvider.GetRequiredService();
+
+ switch (TestConfig.DatabaseBackend)
+ {
+ case DatabaseBackend.TestContainer when !PostgresDatabaseFixture.HasCreatedTemplate:
+ dbContext.Database.Migrate();
+ Seed(dbContext);
+ PostgresDatabaseFixture.CreateTemplateFromCurrentDb();
+ break;
+ case DatabaseBackend.InMemory:
+ if (dbContext.Database.EnsureCreated())
+ {
+ Seed(dbContext);
+ }
+
+ break;
+ }
+ });
}
public TestApiClient CreateTestApiClient()
@@ -56,10 +77,8 @@ public TestApiClient CreateTestApiClient()
HttpClient client = CreateClient();
return new TestApiClient(client);
}
-
private static void Seed(ApplicationContext dbContext)
{
-
Leaderboard leaderboard = new()
{
Name = "Mario Goes to Jail",
@@ -80,4 +99,32 @@ private static void Seed(ApplicationContext dbContext)
dbContext.SaveChanges();
}
+
+ ///
+ /// Deletes and recreates the database
+ ///
+ public void ResetDatabase()
+ {
+ switch (TestConfig.DatabaseBackend)
+ {
+ case DatabaseBackend.InMemory:
+ ResetInMemoryDb();
+ break;
+ case DatabaseBackend.TestContainer:
+ PostgresDatabaseFixture.ResetDatabaseToTemplate();
+ break;
+ default:
+ throw new NotImplementedException("Database reset is not implemented.");
+ }
+ }
+
+ private void ResetInMemoryDb()
+ {
+ using IServiceScope scope = Services.CreateScope();
+ ApplicationContext dbContext = scope.ServiceProvider.GetRequiredService();
+ dbContext.Database.EnsureDeleted();
+ dbContext.Database.EnsureCreated();
+
+ Seed(dbContext);
+ }
}
diff --git a/LeaderboardBackend.Test/Users.cs b/LeaderboardBackend.Test/Users.cs
index 9750476a..b9d44ab8 100644
--- a/LeaderboardBackend.Test/Users.cs
+++ b/LeaderboardBackend.Test/Users.cs
@@ -19,13 +19,25 @@ internal class Users
private const string VALID_PASSWORD = "c00l_pAssword";
private const string VALID_EMAIL = "test@email.com";
- [SetUp]
- public static void SetUp()
+ [OneTimeSetUp]
+ public void OneTimeSetUp()
{
s_factory = new TestApiFactory();
s_apiClient = s_factory.CreateTestApiClient();
}
+ [OneTimeTearDown]
+ public void OneTimeTearDown()
+ {
+ s_factory.Dispose();
+ }
+
+ [SetUp]
+ public void SetUp()
+ {
+ s_factory.ResetDatabase();
+ }
+
[Test]
public static void GetUser_NotFound()
{
diff --git a/LeaderboardBackend/LeaderboardBackend.csproj b/LeaderboardBackend/LeaderboardBackend.csproj
index 85e04ba1..fcb3a41e 100644
--- a/LeaderboardBackend/LeaderboardBackend.csproj
+++ b/LeaderboardBackend/LeaderboardBackend.csproj
@@ -38,7 +38,7 @@
-
+
Always
diff --git a/LeaderboardBackend/Program.cs b/LeaderboardBackend/Program.cs
index 4063c298..1ba38cdf 100644
--- a/LeaderboardBackend/Program.cs
+++ b/LeaderboardBackend/Program.cs
@@ -180,11 +180,6 @@
ApplicationContextConfig config = scope.ServiceProvider
.GetRequiredService>().Value;
- if (config.MigrateDb)
- {
- context.Database.Migrate();
- }
-
if (config.UseInMemoryDb)
{
// If in memory DB, the only way to have an admin user is to seed it at startup.
@@ -199,6 +194,10 @@
context.Users.Add(admin);
await context.SaveChangesAsync();
}
+ else if (config.MigrateDb)
+ {
+ context.Database.Migrate();
+ }
}
app.UseAuthentication();
diff --git a/README.md b/README.md
index 00ac3764..beff62dd 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ This repo is a proof-of-concept for switching to a C# with ASP.NET Core stack. T
* JSON REST API intended for the leaderboards.gg site
* C# with ASP.NET Core, .NET 7
-* Docker containers for PostgreSQL hosting and management run via [Docker Compose](https://docs.docker.com/compose/install/)
+* Docker containers for PostgreSQL hosting and management run via [Docker Compose](https://docs.docker.com/compose/install/), and also for integration tests with [Testcontainers](https://dotnet.testcontainers.org/)
# Project Setup
@@ -65,31 +65,43 @@ After installing a code editor:
## Running the Application
+The application reads configuration from environment variables, a `.env` file, and a `appsettings.json` file.
+
+The easiest is to make a `.env` file. As a starting point, you can copy the template file `example.env` at the project root and rename it to `.env`.
+```bash
+cp example.env .env
+```
+
### Running the Database(s)
+We use Postgres, but have the ability to run Entity Framework's in-memory DB as well instead, which allows quicker debugging.
-We use Postgres, but have the ability to run .NET's in-memory DB as well instead, which allows quicker debugging. If you would like to use the latter, you can set `ApplicationContext__UseInMemoryDb` in your `.env` file to `true`, or not make a `.env` file at all, then skip to the next section. If you would like to use the former, follow these instructions:
+**Running with Postgres is highly encouraged**, as the in-memory DB will not behave the same as Postgres,
+and [some features will not work at all](https://learn.microsoft.com/en-us/ef/core/testing/choosing-a-testing-strategy#in-memory-as-a-database-fake) (for example: transactions and constraints).
-As mentioned above, we run Docker containers for the DB. After [installing Docker Compose](https://docs.docker.com/compose/install/), run these commands in the project root:
+#### Postgres with Docker compose
+As mentioned above, we run Docker containers for the DB. After [installing Docker Compose](https://docs.docker.com/compose/install/), run this command in the project root:
```bash
-cp example.env .env
-sudo docker-compose (or docker compose) up -d
+docker compose up -d
```
+If you're using Linux, you might need to run Docker with `sudo`.
This starts up:
- Adminer which is a GUI manager for the databases
-- The main Postgres DB
-- The test Postgres DB
+- A Postgres DB
+
+If you're using the default values provided in `example.env`, input these values in Adminer for access:
-Using the default values provided in `example.env`, input these values in Adminer for access:
+| Field | Value | Comment
+| -------- | ------------------ | -------
+| System | PostgreSQL |
+| Server | `db` | You cannot set it to `localhost` because it must match the Docker container name
+| Username | `admin` | Corresponds to the `ApplicationContext__PG__USER` config
+| Password | `example` | Corresponds to `ApplicationContext__PG__PASSWORD`
+| Database | `leaderboardsmain` | Corresponds to `ApplicationContext__PG__DB`
-| Field | Value |
-| --- | --- |
-| System | PostgreSQL |
-| Server | db (for main) / db-test (for test) |
-| Username | admin |
-| Password | example |
-| Database | leaderboardsmain / leaderboardstest |
+#### In-memory database
+To use the in-memory database, set `ApplicationContext__UseInMemoryDb` in your `.env` file to `true`.
### Visual Studio
@@ -145,6 +157,8 @@ dotnet run --project LeaderboardBackend --urls https://localhost:7128 // dotnet
The value provided to `--urls` has to match what's listed under `applicationUrl` in `LeaderboardBackend/Properties/launchSettings.json`. As of this writing, it's `https://localhost:7128`.
#### Test the App
+Docker is required to run integration tests against a real Postgres database.
+However, if you would still like to run the tests with an in-memory database, you can set the following environment variable: `INTEGRATION_TESTS_DB_BACKEND=InMemory`.
To run the tests, run the following commands from the root of the project:
diff --git a/docker-compose.yml b/docker-compose.yml
index 52687bf5..fa61c55c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -12,16 +12,6 @@ services:
ports:
- ${ApplicationContext__PG__PORT}:5432
- db-test:
- image: postgres
- restart: always
- environment:
- POSTGRES_USER: ${ApplicationContext__PG__USER}
- POSTGRES_PASSWORD: ${ApplicationContext__PG__PASSWORD}
- POSTGRES_DB: ${ApplicationContext__PG__DB}
- ports:
- - ${POSTGRES_TEST_PORT}:5432
-
adminer:
image: adminer
restart: always
diff --git a/example.env b/example.env
index 766475c7..da71ddda 100644
--- a/example.env
+++ b/example.env
@@ -11,7 +11,5 @@ ApplicationContext__PG__PORT=5432
JWT__KEY=coolsecretkeywow
JWT__ISSUER=leaderboards.gg
-POSTGRES_TEST_PORT=5433
-
ADMINER_PORT=1337