Init
This commit is contained in:
111
Prefab.Tests/Infrastructure/Aspire/AppHost.cs
Normal file
111
Prefab.Tests/Infrastructure/Aspire/AppHost.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Prefab.Tests.Infrastructure.Aspire;
|
||||
|
||||
internal sealed class AppHost(DistributedApplication application, EnvironmentVariableScope? environmentScope) : IAsyncDisposable
|
||||
{
|
||||
public DistributedApplication Application => application;
|
||||
|
||||
public static async Task<AppHost> StartAsync(IReadOnlyDictionary<string, string?>? environmentOverrides, CancellationToken cancellationToken)
|
||||
{
|
||||
EnvironmentVariableScope? scope = null;
|
||||
if (environmentOverrides is not null && environmentOverrides.Count > 0)
|
||||
{
|
||||
scope = new EnvironmentVariableScope(environmentOverrides);
|
||||
}
|
||||
|
||||
DistributedApplication? application = null;
|
||||
try
|
||||
{
|
||||
string[] defaultArgs =
|
||||
[
|
||||
"SqlServer:UseDataVolume=false",
|
||||
"SqlServer:HostPort=",
|
||||
"Parameters:prefab-sql-password=PrefabTests!1234",
|
||||
];
|
||||
|
||||
var builder = await DistributedApplicationTestingBuilder
|
||||
.CreateAsync<Projects.Prefab_AppHost>(defaultArgs, static (_, hostOptions) =>
|
||||
{
|
||||
hostOptions ??= new HostApplicationBuilderSettings();
|
||||
hostOptions.Configuration ??= new ConfigurationManager();
|
||||
}, cancellationToken);
|
||||
|
||||
application = await builder.BuildAsync(cancellationToken);
|
||||
await application.StartAsync(cancellationToken);
|
||||
|
||||
var notifications = application.ResourceNotifications;
|
||||
await notifications.WaitForResourceHealthyAsync("prefab-sql", cancellationToken);
|
||||
await notifications.WaitForResourceHealthyAsync("prefab-catalog", cancellationToken);
|
||||
await notifications.WaitForResourceHealthyAsync("prefab-web", cancellationToken);
|
||||
|
||||
return new AppHost(application, scope);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (application is not null)
|
||||
{
|
||||
await application.DisposeAsync();
|
||||
}
|
||||
|
||||
scope?.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public HttpClient CreateHttpClient(string resourceName) =>
|
||||
application.CreateHttpClient(resourceName);
|
||||
|
||||
public async Task<string> GetSqlConnectionStringAsync(string resourceName, CancellationToken cancellationToken)
|
||||
{
|
||||
var connectionString = await application.GetConnectionStringAsync(resourceName, cancellationToken).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
throw new InvalidOperationException($"SQL resource '{resourceName}' did not provide a connection string.");
|
||||
}
|
||||
|
||||
return connectionString;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await application.StopAsync(CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best effort
|
||||
}
|
||||
|
||||
await application.DisposeAsync();
|
||||
environmentScope?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class EnvironmentVariableScope : IDisposable
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, string?> _overrides;
|
||||
private readonly Dictionary<string, string?> _originalValues = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public EnvironmentVariableScope(IReadOnlyDictionary<string, string?> overrides)
|
||||
{
|
||||
_overrides = overrides;
|
||||
|
||||
foreach (var pair in overrides)
|
||||
{
|
||||
_originalValues[pair.Key] = Environment.GetEnvironmentVariable(pair.Key);
|
||||
Environment.SetEnvironmentVariable(pair.Key, pair.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var pair in _overrides)
|
||||
{
|
||||
_originalValues.TryGetValue(pair.Key, out var value);
|
||||
Environment.SetEnvironmentVariable(pair.Key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
Prefab.Tests/Infrastructure/Aspire/AppHostCollection.cs
Normal file
9
Prefab.Tests/Infrastructure/Aspire/AppHostCollection.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Prefab.Tests.Infrastructure.Aspire;
|
||||
|
||||
/// <summary>
|
||||
/// xUnit collection for tests that share a single Aspire AppHost.
|
||||
/// </summary>
|
||||
[CollectionDefinition("AppHost")]
|
||||
public class AppHostCollection : ICollectionFixture<AppHostFixture>
|
||||
{
|
||||
}
|
||||
117
Prefab.Tests/Infrastructure/Aspire/AppHostFixture.cs
Normal file
117
Prefab.Tests/Infrastructure/Aspire/AppHostFixture.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Prefab.Handler;
|
||||
using Prefab.Web.Data;
|
||||
|
||||
namespace Prefab.Tests.Infrastructure.Aspire;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a reusable Aspire application host for integration tests.
|
||||
/// </summary>
|
||||
public class AppHostFixture : IAsyncLifetime
|
||||
{
|
||||
private static readonly TimeSpan DefaultStartupTimeout = TimeSpan.FromMinutes(2);
|
||||
private AppHost? _host;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the Aspire host has been started.
|
||||
/// </summary>
|
||||
public bool IsRunning => _host is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the active <see cref="AppHost"/>.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">Thrown if the host has not been started.</exception>
|
||||
private AppHost Host => _host ?? throw new InvalidOperationException("The Aspire host has not been started.");
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await StartHostAsync(null, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_host is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _host.DisposeAsync();
|
||||
_host = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restarts the Aspire host with the supplied environment overrides.
|
||||
/// </summary>
|
||||
/// <param name="environmentOverrides">Environment variables to apply when starting the host.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the restart operation.</param>
|
||||
public async Task ReinitializeAsync(
|
||||
IReadOnlyDictionary<string, string?>? environmentOverrides,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await DisposeAsync();
|
||||
await StartHostAsync(environmentOverrides, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an <see cref="HttpClient"/> for the specified resource.
|
||||
/// </summary>
|
||||
public HttpClient CreateHttpClient(string resourceName) => Host.CreateHttpClient(resourceName);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a connection string for the specified resource.
|
||||
/// </summary>
|
||||
public Task<string> GetConnectionStringAsync(string resourceName, CancellationToken cancellationToken) =>
|
||||
Host.GetSqlConnectionStringAsync(resourceName, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Provides an <see cref="AppDb"/> configured for write operations so tests can seed data.
|
||||
/// </summary>
|
||||
/// <param name="seedWork">Delegate that performs the seed work.</param>
|
||||
/// <param name="cancellationToken">Token used to cancel the operation.</param>
|
||||
public async Task SeedTestWith(Func<AppDb, CancellationToken, Task> seedWork, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(seedWork);
|
||||
|
||||
var connectionString = await GetConnectionStringAsync("prefab-db", cancellationToken);
|
||||
|
||||
var options = new DbContextOptionsBuilder<AppDb>()
|
||||
.UseSqlServer(connectionString)
|
||||
.Options;
|
||||
|
||||
await using var db = new AppDb(options, new HandlerContextAccessor());
|
||||
|
||||
await db.Database.MigrateAsync(cancellationToken);
|
||||
|
||||
await seedWork(db, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the Aspire host with the specified environment overrides.
|
||||
/// </summary>
|
||||
private async Task StartHostAsync(IReadOnlyDictionary<string, string?>? environmentOverrides, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_host is not null)
|
||||
{
|
||||
throw new InvalidOperationException("The Aspire host has already been started.");
|
||||
}
|
||||
|
||||
using var cts = cancellationToken.CanBeCanceled
|
||||
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
|
||||
: new CancellationTokenSource(DefaultStartupTimeout);
|
||||
|
||||
cts.CancelAfter(DefaultStartupTimeout);
|
||||
|
||||
var overrides = environmentOverrides ?? BuildEnvironmentOverrides();
|
||||
_host = await AppHost.StartAsync(
|
||||
overrides is { Count: > 0 } ? overrides : null,
|
||||
cts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides the default environment overrides used when starting the host.
|
||||
/// </summary>
|
||||
protected virtual IReadOnlyDictionary<string, string?> BuildEnvironmentOverrides() =>
|
||||
new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
Reference in New Issue
Block a user