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);
|
||||
}
|
||||
197
Prefab.Tests/Infrastructure/AspireInfraHost.cs
Normal file
197
Prefab.Tests/Infrastructure/AspireInfraHost.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Prefab.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Starts the Aspire AppHost while keeping only infrastructure resources (e.g., SQL, cache).
|
||||
/// </summary>
|
||||
public sealed class AspireInfraHost : IAsyncLifetime
|
||||
{
|
||||
private readonly PrefabHarnessOptions _options;
|
||||
private readonly string[] _infraResourcesToKeep;
|
||||
private DistributedApplication? _app;
|
||||
private readonly Dictionary<string, ProjectResource> _projects = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public AspireInfraHost(PrefabHarnessOptions options, params string[] infraResourcesToKeep)
|
||||
{
|
||||
_options = options;
|
||||
_infraResourcesToKeep = infraResourcesToKeep;
|
||||
}
|
||||
|
||||
public DistributedApplication App => _app ?? throw new InvalidOperationException("Aspire is not running.");
|
||||
public IServiceProvider Services => App.Services;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
var appArgs = new List<string>();
|
||||
if (_options.Mode == RunMode.EphemeralIsolated)
|
||||
{
|
||||
var containerName = $"prefab-sql-{Guid.NewGuid():N}"[..19];
|
||||
appArgs.Add("SqlServer:UseDataVolume=false");
|
||||
appArgs.Add("SqlServer:HostPort=");
|
||||
appArgs.Add($"SqlServer:ContainerName={containerName}");
|
||||
}
|
||||
if (_options.DisablePortRandomization)
|
||||
{
|
||||
appArgs.Add("DcpPublisher:RandomizePorts=false");
|
||||
}
|
||||
|
||||
var builder = await DistributedApplicationTestingBuilder.CreateAsync<Projects.Prefab_AppHost>(
|
||||
appArgs.ToArray(),
|
||||
configureBuilder: (appOptions, hostSettings) =>
|
||||
{
|
||||
appOptions.DisableDashboard = !_options.EnableAspireDashboard;
|
||||
|
||||
hostSettings ??= new HostApplicationBuilderSettings();
|
||||
hostSettings.Configuration ??= new ConfigurationManager();
|
||||
});
|
||||
|
||||
if (_options.DisablePortRandomization)
|
||||
{
|
||||
builder.Configuration["DcpPublisher:RandomizePorts"] = "false";
|
||||
}
|
||||
builder.Configuration["ASPIRE_ALLOW_UNSECURED_TRANSPORT"] = "true";
|
||||
|
||||
foreach (var project in builder.Resources.OfType<ProjectResource>())
|
||||
{
|
||||
_projects[project.Name] = project;
|
||||
}
|
||||
|
||||
var keep = new HashSet<string>(_infraResourcesToKeep, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Ensure parent resources remain so infra dependencies stay intact.
|
||||
var added = 0;
|
||||
do
|
||||
{
|
||||
added = 0;
|
||||
foreach (var resource in builder.Resources)
|
||||
{
|
||||
if (keep.Contains(resource.Name))
|
||||
{
|
||||
if (resource is IResourceWithParent withParent && withParent.Parent is { } parent && keep.Add(parent.Name))
|
||||
{
|
||||
added++;
|
||||
}
|
||||
|
||||
var relationships = resource.Annotations.OfType<ResourceRelationshipAnnotation>();
|
||||
foreach (var relationship in relationships.Where(r => r.Type == "Parent"))
|
||||
{
|
||||
if (keep.Add(relationship.Resource.Name))
|
||||
{
|
||||
added++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} while (added > 0);
|
||||
|
||||
foreach (var resource in builder.Resources.Where(r => !keep.Contains(r.Name)).ToArray())
|
||||
{
|
||||
builder.Resources.Remove(resource);
|
||||
}
|
||||
|
||||
if (_options.Mode == RunMode.EphemeralIsolated)
|
||||
{
|
||||
foreach (var resource in builder.Resources)
|
||||
{
|
||||
var lifetime = resource.Annotations.OfType<ContainerLifetimeAnnotation>().FirstOrDefault();
|
||||
if (lifetime is not null)
|
||||
{
|
||||
resource.Annotations.Remove(lifetime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_app = await builder.BuildAsync();
|
||||
await _app.StartAsync();
|
||||
|
||||
foreach (var resource in builder.Resources)
|
||||
{
|
||||
await _app.ResourceNotifications.WaitForResourceHealthyAsync(resource.Name);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_app is not null)
|
||||
{
|
||||
await _app.DisposeAsync();
|
||||
_app = null;
|
||||
}
|
||||
}
|
||||
|
||||
public ProjectResource GetProject(string name)
|
||||
{
|
||||
if (_projects.TryGetValue(name, out var project))
|
||||
{
|
||||
return project;
|
||||
}
|
||||
|
||||
throw new ArgumentException($"Project '{name}' not found in AppHost model.", nameof(name));
|
||||
}
|
||||
|
||||
public async Task<string> GetConnectionStringAsync(string resourceName, CancellationToken cancellationToken = default) =>
|
||||
(await App.GetConnectionStringAsync(resourceName, cancellationToken))
|
||||
?? throw new InvalidOperationException($"Connection string for '{resourceName}' is not available.");
|
||||
|
||||
public static async Task<Dictionary<string, string?>> ResolveConfigForProjectAsync(
|
||||
ProjectResource project,
|
||||
IServiceProvider services,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var config = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (project is not IResourceWithEnvironment withEnvironment ||
|
||||
!withEnvironment.TryGetEnvironmentVariables(out var annotations))
|
||||
{
|
||||
return config;
|
||||
}
|
||||
|
||||
var execOptions = new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run)
|
||||
{
|
||||
ServiceProvider = services
|
||||
};
|
||||
var execContext = new DistributedApplicationExecutionContext(execOptions);
|
||||
var envContext = new EnvironmentCallbackContext(execContext, cancellationToken: cancellationToken);
|
||||
|
||||
foreach (var annotation in annotations)
|
||||
{
|
||||
await annotation.Callback(envContext);
|
||||
}
|
||||
|
||||
foreach (var (key, value) in envContext.EnvironmentVariables)
|
||||
{
|
||||
if (string.Equals(key, "ASPNETCORE_URLS", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var resolved = value switch
|
||||
{
|
||||
string s => s,
|
||||
IValueProvider provider => await SafeGet(provider, cancellationToken),
|
||||
_ => null
|
||||
};
|
||||
|
||||
if (resolved is not null)
|
||||
{
|
||||
config[key.Replace("__", ":")] = resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
|
||||
static async Task<string?> SafeGet(IValueProvider provider, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await provider.GetValueAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
110
Prefab.Tests/Infrastructure/KestrelInProcApp.cs
Normal file
110
Prefab.Tests/Infrastructure/KestrelInProcApp.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace Prefab.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Hosts a project entry point in-process with Kestrel so integration tests hit real HTTP.
|
||||
/// </summary>
|
||||
public sealed class KestrelInProcApp<TEntryPoint> : WebApplicationFactory<TEntryPoint>, IAsyncLifetime
|
||||
where TEntryPoint : class
|
||||
{
|
||||
private readonly AspireInfraHost _infra;
|
||||
private readonly ProjectResource _project;
|
||||
private readonly int? _port;
|
||||
private readonly IDictionary<string, string?> _overrides;
|
||||
private readonly Action<IServiceCollection>? _configureServices;
|
||||
private Dictionary<string, string?> _config = new(StringComparer.OrdinalIgnoreCase);
|
||||
private Uri? _httpAddress;
|
||||
|
||||
public KestrelInProcApp(
|
||||
AspireInfraHost infra,
|
||||
ProjectResource project,
|
||||
int? port,
|
||||
IDictionary<string, string?>? overrides = null,
|
||||
Action<IServiceCollection>? configureServices = null)
|
||||
{
|
||||
_infra = infra;
|
||||
_project = project;
|
||||
_port = port;
|
||||
_overrides = overrides ?? new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
_configureServices = configureServices;
|
||||
|
||||
this.UseKestrel(_port ?? 0);
|
||||
}
|
||||
|
||||
public Uri HttpAddress => _httpAddress ?? throw new InvalidOperationException("Application not started.");
|
||||
public IServiceProvider AppServices => Services;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_config = await AspireInfraHost.ResolveConfigForProjectAsync(_project, _infra.Services);
|
||||
|
||||
foreach (var (key, value) in _overrides)
|
||||
{
|
||||
_config[key.Replace("__", ":")] = value;
|
||||
}
|
||||
|
||||
using var client = CreateClient();
|
||||
_httpAddress = client.BaseAddress ?? ResolveServerAddress();
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
foreach (var (key, value) in _config)
|
||||
{
|
||||
if (string.Equals(key, "ASPNETCORE_URLS", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.UseSetting(key, value);
|
||||
}
|
||||
|
||||
builder.UseSetting("ASPNETCORE_ENVIRONMENT", "Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
RemoveHostedService(services, "Prefab.Web.Workers.DataSeeder");
|
||||
RemoveHostedService(services, "Prefab.Catalog.Api.Workers.DataSeeder");
|
||||
_configureServices?.Invoke(services);
|
||||
});
|
||||
}
|
||||
|
||||
public override ValueTask DisposeAsync() => base.DisposeAsync();
|
||||
|
||||
private Uri ResolveServerAddress()
|
||||
{
|
||||
var server = Services.GetRequiredService<IServer>();
|
||||
var feature = server.Features.Get<IServerAddressesFeature>();
|
||||
var address = feature?.Addresses.FirstOrDefault();
|
||||
if (address is not null && Uri.TryCreate(address, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return uri;
|
||||
}
|
||||
|
||||
// Fallback to localhost with known (or dynamic) port.
|
||||
var port = _port.GetValueOrDefault(0);
|
||||
return new Uri($"http://127.0.0.1:{port}");
|
||||
}
|
||||
|
||||
private static void RemoveHostedService(IServiceCollection services, string typeFullName)
|
||||
{
|
||||
var descriptors = services
|
||||
.Where(s => s.ServiceType == typeof(IHostedService) && s.ImplementationType?.FullName == typeFullName)
|
||||
.ToList();
|
||||
|
||||
foreach (var descriptor in descriptors)
|
||||
{
|
||||
services.Remove(descriptor);
|
||||
}
|
||||
}
|
||||
}
|
||||
206
Prefab.Tests/Infrastructure/PrefabCompositeFixture.cs
Normal file
206
Prefab.Tests/Infrastructure/PrefabCompositeFixture.cs
Normal file
@@ -0,0 +1,206 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Prefab.Handler;
|
||||
using Prefab.Web.Data;
|
||||
|
||||
namespace Prefab.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Spins up Aspire infrastructure plus the Prefab web and catalog apps in-process.
|
||||
/// </summary>
|
||||
public class PrefabCompositeFixture : IAsyncLifetime
|
||||
{
|
||||
public const int DebugPrefabPort = 5173;
|
||||
public const int DebugCatalogPort = 5174;
|
||||
|
||||
private readonly PrefabHarnessOptions _options;
|
||||
private readonly TestAuthenticationStateProvider _authenticationProvider = new();
|
||||
|
||||
protected PrefabCompositeFixture(PrefabHarnessOptions? options = null)
|
||||
{
|
||||
_options = options ?? ResolveDefaultOptions();
|
||||
Infra = new AspireInfraHost(_options, "prefab-db");
|
||||
}
|
||||
|
||||
public AspireInfraHost Infra { get; }
|
||||
public KestrelInProcApp<Prefab.Web.Module> PrefabWeb { get; private set; } = default!;
|
||||
public KestrelInProcApp<Prefab.Catalog.Api.Module>? CatalogApi { get; private set; }
|
||||
public TestAuthenticationStateProvider AuthenticationStateProvider => _authenticationProvider;
|
||||
|
||||
public Uri PrefabBaseAddress => PrefabWeb.HttpAddress;
|
||||
public Uri CatalogBaseAddress => CatalogApi?.HttpAddress ?? throw new InvalidOperationException("Catalog API has not been started.");
|
||||
|
||||
protected virtual bool ShouldStartCatalog => true;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await Infra.InitializeAsync();
|
||||
await EnsureDatabaseInitialized(CancellationToken.None);
|
||||
|
||||
var prefabProject = Infra.GetProject("prefab-web");
|
||||
var useFixedPorts = _options.Mode == RunMode.DebugPersistent && _options.DisablePortRandomization;
|
||||
|
||||
Uri? catalogAddress = null;
|
||||
if (ShouldStartCatalog)
|
||||
{
|
||||
var catalogProject = Infra.GetProject("prefab-catalog");
|
||||
var catalogPort = useFixedPorts ? DebugCatalogPort : (int?)null;
|
||||
CatalogApi = new KestrelInProcApp<Prefab.Catalog.Api.Module>(
|
||||
Infra,
|
||||
catalogProject,
|
||||
catalogPort,
|
||||
overrides: null,
|
||||
configureServices: ConfigureTestAuthentication);
|
||||
|
||||
await CatalogApi.InitializeAsync();
|
||||
catalogAddress = CatalogApi.HttpAddress;
|
||||
}
|
||||
else
|
||||
{
|
||||
CatalogApi = null;
|
||||
}
|
||||
|
||||
var overrides = GetWebOverrides(catalogAddress);
|
||||
|
||||
var prefabPort = useFixedPorts ? DebugPrefabPort : (int?)null;
|
||||
PrefabWeb = new KestrelInProcApp<Prefab.Web.Module>(
|
||||
Infra,
|
||||
prefabProject,
|
||||
prefabPort,
|
||||
overrides,
|
||||
ConfigureTestAuthentication);
|
||||
|
||||
await PrefabWeb.InitializeAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Infra.DisposeAsync();
|
||||
}
|
||||
|
||||
public HttpClient CreateHttpClientForWeb() => PrefabWeb.CreateClient();
|
||||
|
||||
public IServiceScope CreateWebScope() => PrefabWeb.AppServices.CreateScope();
|
||||
|
||||
public async Task<string> GetConnectionStringAsync(string resourceName, CancellationToken cancellationToken = default) =>
|
||||
await Infra.GetConnectionStringAsync(resourceName, cancellationToken);
|
||||
|
||||
public async Task SeedAsync(Func<AppDb, CancellationToken, Task> seed, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(seed);
|
||||
|
||||
var connectionString = await GetConnectionStringAsync("prefab-db", cancellationToken);
|
||||
var optionsBuilder = new DbContextOptionsBuilder<AppDb>().UseSqlServer(connectionString);
|
||||
|
||||
await using var db = new AppDb(optionsBuilder.Options, new HandlerContextAccessor());
|
||||
await db.Database.MigrateAsync(cancellationToken);
|
||||
await seed(db, cancellationToken);
|
||||
}
|
||||
|
||||
protected virtual async ValueTask EnsureDatabaseInitialized(CancellationToken cancellationToken)
|
||||
{
|
||||
var connectionString = await GetConnectionStringAsync("prefab-db", cancellationToken);
|
||||
var optionsBuilder = new DbContextOptionsBuilder<AppDb>().UseSqlServer(connectionString);
|
||||
|
||||
await using var db = new AppDb(optionsBuilder.Options, new HandlerContextAccessor());
|
||||
await db.Database.MigrateAsync(cancellationToken);
|
||||
}
|
||||
|
||||
protected virtual IDictionary<string, string?> GetWebOverrides(Uri? catalogAddress) =>
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["Prefab__Catalog__Client__Transport"] = "Http",
|
||||
["Prefab__Catalog__Client__BaseAddress"] = catalogAddress?.ToString().TrimEnd('/') ?? string.Empty
|
||||
};
|
||||
|
||||
private void ConfigureTestAuthentication(IServiceCollection services)
|
||||
{
|
||||
var descriptors = services.Where(d => d.ServiceType == typeof(AuthenticationStateProvider)).ToList();
|
||||
foreach (var descriptor in descriptors)
|
||||
{
|
||||
services.Remove(descriptor);
|
||||
}
|
||||
|
||||
services.AddSingleton(_authenticationProvider);
|
||||
services.AddSingleton<AuthenticationStateProvider>(_ => _authenticationProvider);
|
||||
}
|
||||
|
||||
private static PrefabHarnessOptions ResolveDefaultOptions()
|
||||
{
|
||||
var modeEnv = Environment.GetEnvironmentVariable("PREFAB_TEST_MODE")?.Trim().ToLowerInvariant();
|
||||
var mode = modeEnv switch
|
||||
{
|
||||
"debug" => RunMode.DebugPersistent,
|
||||
"ephemeral" => RunMode.EphemeralIsolated,
|
||||
_ => RunMode.DebugPersistent
|
||||
};
|
||||
|
||||
return mode switch
|
||||
{
|
||||
RunMode.EphemeralIsolated => new PrefabHarnessOptions(
|
||||
RunMode.EphemeralIsolated,
|
||||
EnableAspireDashboard: false,
|
||||
DisablePortRandomization: false),
|
||||
_ => new PrefabHarnessOptions(
|
||||
RunMode.DebugPersistent,
|
||||
EnableAspireDashboard: true,
|
||||
DisablePortRandomization: true)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PrefabCompositeFixture_Debug : PrefabCompositeFixture
|
||||
{
|
||||
public PrefabCompositeFixture_Debug()
|
||||
: base(new PrefabHarnessOptions(
|
||||
RunMode.DebugPersistent,
|
||||
EnableAspireDashboard: true,
|
||||
DisablePortRandomization: true))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PrefabCompositeFixture_Ephemeral : PrefabCompositeFixture
|
||||
{
|
||||
public PrefabCompositeFixture_Ephemeral()
|
||||
: base(new PrefabHarnessOptions(
|
||||
RunMode.EphemeralIsolated,
|
||||
EnableAspireDashboard: false,
|
||||
DisablePortRandomization: false))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PrefabCompositeFixture_InProcess : PrefabCompositeFixture
|
||||
{
|
||||
public PrefabCompositeFixture_InProcess()
|
||||
: base(new PrefabHarnessOptions(
|
||||
RunMode.DebugPersistent,
|
||||
EnableAspireDashboard: true,
|
||||
DisablePortRandomization: true))
|
||||
{
|
||||
}
|
||||
|
||||
protected override bool ShouldStartCatalog => false;
|
||||
|
||||
protected override IDictionary<string, string?> GetWebOverrides(Uri? catalogAddress) =>
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["Prefab__Catalog__Client__Transport"] = "InProcess"
|
||||
};
|
||||
}
|
||||
|
||||
[CollectionDefinition("Prefab.Debug")]
|
||||
public sealed class PrefabDebugCollection : ICollectionFixture<PrefabCompositeFixture_Debug>
|
||||
{
|
||||
}
|
||||
|
||||
[CollectionDefinition("Prefab.Ephemeral")]
|
||||
public sealed class PrefabEphemeralCollection : ICollectionFixture<PrefabCompositeFixture_Ephemeral>
|
||||
{
|
||||
}
|
||||
|
||||
[CollectionDefinition("Prefab.InProcess")]
|
||||
public sealed class PrefabInProcessCollection : ICollectionFixture<PrefabCompositeFixture_InProcess>
|
||||
{
|
||||
}
|
||||
12
Prefab.Tests/Infrastructure/PrefabHarnessOptions.cs
Normal file
12
Prefab.Tests/Infrastructure/PrefabHarnessOptions.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace Prefab.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Configures the reusable harness with the desired run mode and optional knobs.
|
||||
/// </summary>
|
||||
/// <param name="Mode">The execution mode (debug or ephemeral).</param>
|
||||
/// <param name="EnableAspireDashboard">True to keep the Aspire dashboard enabled.</param>
|
||||
/// <param name="DisablePortRandomization">True to disable randomized ports in Aspire.</param>
|
||||
public sealed record PrefabHarnessOptions(
|
||||
RunMode Mode,
|
||||
bool EnableAspireDashboard = false,
|
||||
bool DisablePortRandomization = false);
|
||||
17
Prefab.Tests/Infrastructure/RunMode.cs
Normal file
17
Prefab.Tests/Infrastructure/RunMode.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace Prefab.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the execution mode for the reusable test harness.
|
||||
/// </summary>
|
||||
public enum RunMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Fixed ports and persistent infrastructure resources; ideal for F5 debugging.
|
||||
/// </summary>
|
||||
DebugPersistent,
|
||||
|
||||
/// <summary>
|
||||
/// Dynamic ports and disposable infrastructure; safe for parallel CI execution.
|
||||
/// </summary>
|
||||
EphemeralIsolated
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
|
||||
namespace Prefab.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Simplified authentication provider for tests. Returns a configurable principal and works outside Blazor circuits.
|
||||
/// </summary>
|
||||
public sealed class TestAuthenticationStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
private ClaimsPrincipal _principal = CreateAnonymous();
|
||||
|
||||
public override Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||
{
|
||||
ClaimsPrincipal principal;
|
||||
lock (_sync)
|
||||
{
|
||||
principal = _principal;
|
||||
}
|
||||
|
||||
return Task.FromResult(new AuthenticationState(principal));
|
||||
}
|
||||
|
||||
public void SetPrincipal(ClaimsPrincipal? principal)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_principal = principal ?? CreateAnonymous();
|
||||
}
|
||||
|
||||
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(_principal)));
|
||||
}
|
||||
|
||||
public void SetAnonymous() => SetPrincipal(null);
|
||||
|
||||
private static ClaimsPrincipal CreateAnonymous() => new(new ClaimsIdentity());
|
||||
}
|
||||
Reference in New Issue
Block a user