Refactor integration test TestHostBuilder setup to shared abstract class (#512)

This commit is contained in:
Leendert de Borst
2025-01-03 15:22:47 +01:00
parent 50cab3a2f3
commit c123edccd4
6 changed files with 349 additions and 276 deletions

View File

@@ -0,0 +1,161 @@
// -----------------------------------------------------------------------
// <copyright file="AbstractTestHostBuilder.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
// -----------------------------------------------------------------------
namespace AliasVault.IntegrationTests;
using AliasServerDb;
using AliasServerDb.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Npgsql;
/// <summary>
/// Builder class for creating a test host for services in order to run integration tests against them. This class
/// contains common logic such as creating a temporary database.
/// </summary>
public class AbstractTestHostBuilder : IAsyncDisposable
{
/// <summary>
/// The DbContextFactory instance that is created for the test.
/// </summary>
private IAliasServerDbContextFactory _dbContextFactory = null!;
/// <summary>
/// The cached DbContext instance that can be used during the test.
/// </summary>
private AliasServerDbContext? _dbContext;
/// <summary>
/// The temporary database name for the test.
/// </summary>
private string? _tempDbName;
/// <summary>
/// Returns the DbContext instance for the test. This can be used to seed the database with test data.
/// </summary>
/// <returns>AliasServerDbContext instance.</returns>
public AliasServerDbContext GetDbContext()
{
if (_dbContext != null)
{
return _dbContext;
}
_dbContext = _dbContextFactory.CreateDbContext();
return _dbContext;
}
/// <summary>
/// Returns the DbContext instance for the test. This can be used to seed the database with test data.
/// </summary>
/// <returns>AliasServerDbContext instance.</returns>
public async Task<AliasServerDbContext> GetDbContextAsync()
{
return await _dbContextFactory.CreateDbContextAsync();
}
/// <summary>
/// Disposes of the test host and cleans up the temporary database.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_dbContext != null)
{
await _dbContext.DisposeAsync();
_dbContext = null;
}
if (!string.IsNullOrEmpty(_tempDbName))
{
// Create a connection to 'postgres' database to drop the test database
using var conn =
new NpgsqlConnection(
"Host=localhost;Port=5433;Database=postgres;Username=aliasvault;Password=password");
await conn.OpenAsync();
// First terminate existing connections
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"""
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = '{_tempDbName}';
""";
await cmd.ExecuteNonQueryAsync();
}
// Then drop the database
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"""
DROP DATABASE IF EXISTS "{_tempDbName}";
""";
await cmd.ExecuteNonQueryAsync();
}
GC.SuppressFinalize(this);
}
}
/// <summary>
/// Creates a new test host builder with test database connection already configured.
/// </summary>
/// <returns>IHost.</returns>
protected IHostBuilder CreateBuilder()
{
_tempDbName = $"aliasdb_test_{Guid.NewGuid()}";
// Create a connection to 'postgres' database to ensure the test database exists
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
using (var conn = new NpgsqlConnection("Host=localhost;Port=5433;Database=postgres;Username=aliasvault;Password=password"))
{
conn.Open();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"""
CREATE DATABASE "{_tempDbName}";
""";
cmd.ExecuteNonQuery();
}
}
// Create a connection to the new test database
var dbConnection = new NpgsqlConnection($"Host=localhost;Port=5433;Database={_tempDbName};Username=aliasvault;Password=password");
var builder = Host.CreateDefaultBuilder()
.ConfigureServices((context, services) =>
{
// Override configuration
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddInMemoryCollection(new Dictionary<string, string?>
{
["DatabaseProvider"] = "postgresql",
["ConnectionStrings:AliasServerDbContext"] = dbConnection.ConnectionString,
})
.Build();
services.AddSingleton<IConfiguration>(configuration);
services.AddAliasVaultDatabaseConfiguration(configuration);
// Ensure the in-memory database is populated with tables
var serviceProvider = services.BuildServiceProvider();
using (var scope = serviceProvider.CreateScope())
{
_dbContextFactory = scope.ServiceProvider.GetRequiredService<IAliasServerDbContextFactory>();
var dbContext = _dbContextFactory.CreateDbContext();
dbContext.Database.Migrate();
}
});
return builder;
}
}

View File

@@ -7,185 +7,60 @@
namespace AliasVault.IntegrationTests.SmtpServer;
using System.Data.Common;
using AliasServerDb;
using AliasServerDb.Configuration;
using AliasVault.SmtpService;
using AliasVault.SmtpService.Handlers;
using AliasVault.SmtpService.Workers;
using global::SmtpServer;
using global::SmtpServer.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Npgsql;
/// <summary>
/// Builder class for creating a test host for the SmtpServiceWorker in order to run integration tests against it.
/// </summary>
public class TestHostBuilder : IAsyncDisposable
public class TestHostBuilder : AbstractTestHostBuilder
{
/// <summary>
/// The DbContextFactory instance that is created for the test.
/// </summary>
private IAliasServerDbContextFactory _dbContextFactory = null!;
/// <summary>
/// The cached DbContext instance that can be used during the test.
/// </summary>
private AliasServerDbContext? _dbContext;
/// <summary>
/// The temporary database name for the test.
/// </summary>
private string? _tempDbName;
/// <summary>
/// Returns the DbContext instance for the test. This can be used to seed the database with test data.
/// </summary>
/// <returns>AliasServerDbContext instance.</returns>
public AliasServerDbContext GetDbContext()
{
if (_dbContext != null)
{
return _dbContext;
}
_dbContext = _dbContextFactory.CreateDbContext();
return _dbContext;
}
/// <summary>
/// Builds the SmtpService test host.
/// Builds the SmtpService test host with a provided database connection.
/// </summary>
/// <returns>IHost.</returns>
public IHost Build()
{
_tempDbName = $"aliasdb_test_{Guid.NewGuid()}";
// Get base builder with database connection already configured.
var builder = CreateBuilder();
// Create a connection to 'postgres' database to ensure the test database exists
using (var conn = new NpgsqlConnection("Host=localhost;Port=5433;Database=postgres;Username=aliasvault;Password=password"))
// Add specific services for the TestExceptionWorker.
builder.ConfigureServices((context, services) =>
{
conn.Open();
using (var cmd = conn.CreateCommand())
services.AddSingleton(new Config
{
cmd.CommandText = $"""
CREATE DATABASE "{_tempDbName}";
""";
cmd.ExecuteNonQuery();
}
}
AllowedToDomains = new List<string> { "example.tld" },
SmtpTlsEnabled = "false",
});
// Create a connection to the new test database
var dbConnection = new NpgsqlConnection($"Host=localhost;Port=5433;Database={_tempDbName};Username=aliasvault;Password=password");
return Build(dbConnection);
}
/// <summary>
/// Builds the SmtpService test host with a provided database connection.
/// </summary>
/// <param name="dbConnection">The database connection to use for the test.</param>
/// <returns>IHost.</returns>
public IHost Build(DbConnection dbConnection)
{
var builder = Host.CreateDefaultBuilder()
.ConfigureServices((context, services) =>
{
// Override configuration
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddInMemoryCollection(new Dictionary<string, string?>
{
["DatabaseProvider"] = "postgresql",
["ConnectionStrings:AliasServerDbContext"] = dbConnection.ConnectionString,
})
.Build();
services.AddSingleton<IConfiguration>(configuration);
services.AddSingleton(new Config
services.AddTransient<IMessageStore, DatabaseMessageStore>();
services.AddSingleton<SmtpServer>(
provider =>
{
AllowedToDomains = new List<string> { "example.tld" },
SmtpTlsEnabled = "false",
var options = new SmtpServerOptionsBuilder()
.ServerName("aliasvault");
// Note: port 25 doesn't work in GitHub actions so we use these instead for the integration tests:
// - 2525 for the SMTP server
// - 5870 for the submission server
options.Endpoint(serverBuilder =>
serverBuilder
.Port(2525, false))
.Endpoint(serverBuilder =>
serverBuilder
.Port(5870, false));
return new SmtpServer(options.Build(), provider.GetRequiredService<IServiceProvider>());
});
services.AddTransient<IMessageStore, DatabaseMessageStore>();
services.AddSingleton<SmtpServer>(
provider =>
{
var options = new SmtpServerOptionsBuilder()
.ServerName("aliasvault");
// Note: port 25 doesn't work in GitHub actions so we use these instead for the integration tests:
// - 2525 for the SMTP server
// - 5870 for the submission server
options.Endpoint(serverBuilder =>
serverBuilder
.Port(2525, false))
.Endpoint(serverBuilder =>
serverBuilder
.Port(5870, false));
return new SmtpServer(options.Build(), provider.GetRequiredService<IServiceProvider>());
});
services.AddAliasVaultDatabaseConfiguration(configuration);
services.AddHostedService<SmtpServerWorker>();
// Ensure the in-memory database is populated with tables
var serviceProvider = services.BuildServiceProvider();
using (var scope = serviceProvider.CreateScope())
{
_dbContextFactory = scope.ServiceProvider.GetRequiredService<IAliasServerDbContextFactory>();
var dbContext = _dbContextFactory.CreateDbContext();
dbContext.Database.Migrate();
}
});
services.AddHostedService<SmtpServerWorker>();
});
return builder.Build();
}
/// <summary>
/// Disposes of the test host and cleans up the temporary database.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_dbContext != null)
{
await _dbContext.DisposeAsync();
_dbContext = null;
}
if (!string.IsNullOrEmpty(_tempDbName))
{
// Create a connection to 'postgres' database to drop the test database
using var conn = new NpgsqlConnection("Host=localhost;Port=5433;Database=postgres;Username=aliasvault;Password=password");
await conn.OpenAsync();
// First terminate existing connections
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"""
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = '{_tempDbName}';
""";
await cmd.ExecuteNonQueryAsync();
}
// Then drop the database
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"""
DROP DATABASE IF EXISTS "{_tempDbName}";
""";
await cmd.ExecuteNonQueryAsync();
}
GC.SuppressFinalize(this);
}
}
}

View File

@@ -0,0 +1,73 @@
//-----------------------------------------------------------------------
// <copyright file="StatusHostedServiceTests.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.IntegrationTests.StatusHostedService;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
/// <summary>
/// Integration tests for StatusHostedService wrapper.
/// </summary>
[TestFixture]
public class StatusHostedServiceTests
{
/// <summary>
/// The test host instance.
/// </summary>
private IHost _testHost;
/// <summary>
/// The test host builder instance.
/// </summary>
private TestHostBuilder _testHostBuilder;
/// <summary>
/// Setup logic for every test.
/// </summary>
[SetUp]
public void Setup()
{
_testHostBuilder = new TestHostBuilder();
_testHost = _testHostBuilder.Build();
}
/// <summary>
/// Tear down logic for every test.
/// </summary>
/// <returns>Task.</returns>
[TearDown]
public async Task TearDown()
{
await _testHost.StopAsync();
_testHost.Dispose();
await _testHostBuilder.DisposeAsync();
}
/// <summary>
/// Tests that the StatusHostedService properly logs errors from the wrapped service.
/// </summary>
/// <returns>Task.</returns>
[Test]
public async Task LogsExceptionFromWrappedService()
{
// Start the service which will trigger the TestExceptionWorker to throw
await _testHost.StartAsync();
// Give it a moment to process
await Task.Delay(5000);
// Check the logs for the expected error
await using var dbContext = _testHostBuilder.GetDbContext();
var errorLog = await dbContext.Logs
.OrderByDescending(l => l.TimeStamp)
.FirstOrDefaultAsync(l => l.Level == "Error" && l.Exception.Contains("Test exception"));
Assert.That(errorLog, Is.Not.Null, "Expected error log from TestExceptionWorker was not found");
Assert.That(errorLog.Message, Does.Contain("An error occurred in StatusHostedService"), "Error log does not contain expected message from StatusHostedService");
}
}

View File

@@ -0,0 +1,23 @@
//-----------------------------------------------------------------------
// <copyright file="TestExceptionWorker.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.IntegrationTests.StatusHostedService;
using Microsoft.Extensions.Hosting;
/// <summary>
/// A simple worker that throws an exception during task execution. This is used for testing purposes.
/// </summary>
public class TestExceptionWorker() : BackgroundService
{
/// <inheritdoc/>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Delay(TimeSpan.FromMilliseconds(100), stoppingToken);
throw new Exception("Test exception");
}
}

View File

@@ -0,0 +1,49 @@
// -----------------------------------------------------------------------
// <copyright file="TestHostBuilder.cs" company="lanedirt">
// Copyright (c) lanedirt. All rights reserved.
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
// </copyright>
// -----------------------------------------------------------------------
namespace AliasVault.IntegrationTests.StatusHostedService;
using System.Data.Common;
using System.Reflection;
using AliasServerDb;
using AliasServerDb.Configuration;
using AliasVault.WorkerStatus.ServiceExtensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Npgsql;
/// <summary>
/// Builder class for creating a test host for the StatusHostedService wrapper in order to run integration tests
/// against it. This primarily tests basic functionality of the hosted service such as starting, stopping and error
/// handling.
///
/// The StatusHostedService is a wrapper around the HostedService class that provides additional functionality for
/// managing the status of the hosted service. This includes being able to start and stop the services from the
/// AliasVault admin panel.
/// </summary>
public class TestHostBuilder : AbstractTestHostBuilder
{
/// <summary>
/// Builds the test host for the TestExceptionWorker.
/// </summary>
/// <returns>IHost.</returns>
public IHost Build()
{
// Get base builder with database connection already configured.
var builder = CreateBuilder();
// Add specific services for the TestExceptionWorker.
builder.ConfigureServices((context, services) =>
{
services.AddStatusHostedService<TestExceptionWorker, AliasServerDbContext>(Assembly.GetExecutingAssembly().GetName().Name!);
});
return builder.Build();
}
}

View File

@@ -7,149 +7,41 @@
namespace AliasVault.IntegrationTests.TaskRunner;
using AliasServerDb;
using AliasServerDb.Configuration;
using AliasVault.Shared.Server.Services;
using AliasVault.TaskRunner.Tasks;
using AliasVault.TaskRunner.Workers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Npgsql;
/// <summary>
/// Builder class for creating a test host for the TaskRunner in order to run integration tests against it.
/// </summary>
public class TestHostBuilder : IAsyncDisposable
public class TestHostBuilder : AbstractTestHostBuilder
{
/// <summary>
/// The DbContextFactory instance that is created for the test.
/// </summary>
private IAliasServerDbContextFactory _dbContextFactory = null!;
/// <summary>
/// The cached DbContext instance that can be used during the test.
/// </summary>
private AliasServerDbContext? _dbContext;
/// <summary>
/// The temporary database name for the test.
/// </summary>
private string? _tempDbName;
/// <summary>
/// Returns the DbContext instance for the test. This can be used to seed the database with test data.
/// </summary>
/// <returns>AliasServerDbContext instance.</returns>
public async Task<AliasServerDbContext> GetDbContextAsync()
{
return await _dbContextFactory.CreateDbContextAsync();
}
/// <summary>
/// Builds the TaskRunner test host.
/// </summary>
/// <returns>IHost.</returns>
public IHost Build()
{
// Create a temporary database for the test
_tempDbName = $"aliasdb_test_{Guid.NewGuid()}";
// Get base builder with database connection already configured.
var builder = CreateBuilder();
// Create a connection to 'postgres' database to create the test database
using (var conn = new NpgsqlConnection("Host=localhost;Port=5433;Database=postgres;Username=aliasvault;Password=password"))
// Add specific services for the TestExceptionWorker.
builder.ConfigureServices((context, services) =>
{
conn.Open();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"""
CREATE DATABASE "{_tempDbName}";
""";
cmd.ExecuteNonQuery();
}
}
// Add server settings service
services.AddSingleton<ServerSettingsService>();
// Create the connection to the new test database
var dbConnection = new NpgsqlConnection($"Host=localhost;Port=5433;Database={_tempDbName};Username=aliasvault;Password=password");
var builder = Host.CreateDefaultBuilder()
.ConfigureServices((context, services) =>
{
// Override configuration
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddInMemoryCollection(new Dictionary<string, string?>
{
["DatabaseProvider"] = "postgresql",
["ConnectionStrings:AliasServerDbContext"] = dbConnection.ConnectionString,
})
.Build();
services.AddSingleton<IConfiguration>(configuration);
// Add maintenance tasks
services.AddTransient<IMaintenanceTask, LogCleanupTask>();
services.AddTransient<IMaintenanceTask, EmailCleanupTask>();
services.AddTransient<IMaintenanceTask, EmailQuotaCleanupTask>();
// Add server settings service
services.AddSingleton<ServerSettingsService>();
// Add maintenance tasks
services.AddTransient<IMaintenanceTask, LogCleanupTask>();
services.AddTransient<IMaintenanceTask, EmailCleanupTask>();
services.AddTransient<IMaintenanceTask, EmailQuotaCleanupTask>();
services.AddAliasVaultDatabaseConfiguration(configuration);
// Add the TaskRunner worker
services.AddHostedService<TaskRunnerWorker>();
// Ensure the database is populated with tables
var serviceProvider = services.BuildServiceProvider();
using (var scope = serviceProvider.CreateScope())
{
_dbContextFactory = scope.ServiceProvider.GetRequiredService<IAliasServerDbContextFactory>();
var dbContext = _dbContextFactory.CreateDbContext();
dbContext.Database.Migrate();
}
});
// Add the TaskRunner worker
services.AddHostedService<TaskRunnerWorker>();
});
return builder.Build();
}
/// <summary>
/// Disposes of the test host and cleans up the temporary database.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_dbContext != null)
{
await _dbContext.DisposeAsync();
_dbContext = null;
}
if (!string.IsNullOrEmpty(_tempDbName))
{
// Create a connection to 'postgres' database to drop the test database
using var conn = new NpgsqlConnection("Host=localhost;Port=5433;Database=postgres;Username=aliasvault;Password=password");
await conn.OpenAsync();
// First terminate existing connections
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"""
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = '{_tempDbName}';
""";
await cmd.ExecuteNonQueryAsync();
}
// Then drop the database
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = $"""
DROP DATABASE IF EXISTS "{_tempDbName}";
""";
await cmd.ExecuteNonQueryAsync();
}
}
GC.SuppressFinalize(this);
}
}