Use block-scoped namespaces in the test suite
Converts all 73 test source files from file-scoped (namespace X;) to
block-scoped (namespace X { ... }) declarations, re-indenting their
contents accordingly. Matches the existing convention already used in
several production files (e.g. Musify.Infrastructure.Configuration,
Musify.Application.Contracts).
Mechanical change only, done with a script, verified with a full
rebuild (0 warnings, 0 errors) and test run (257/257 passing).
This commit is contained in:
@@ -11,111 +11,112 @@ using NSubstitute;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Api.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Boots the real <c>Musify.Api</c> host — real routing, real auth/authorization pipeline,
|
||||
/// real validators, real Mediator handlers — against a private Postgres container (so EF
|
||||
/// queries behave like production, not like a stand-in). The services this API talks to over
|
||||
/// the network (object storage, YouTube, the message bus, stream-ticket signing) are replaced
|
||||
/// with NSubstitute fakes: they are Infrastructure's job to verify against the real thing (see
|
||||
/// Musify.Infrastructure.Tests), not the HTTP layer's.
|
||||
/// </summary>
|
||||
public sealed class ApiTestFixture : WebApplicationFactory<Program>, IAsyncLifetime
|
||||
namespace Musify.Api.Tests.TestSupport
|
||||
{
|
||||
private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine")
|
||||
.WithDatabase("musify_api_test")
|
||||
.WithUsername("postgres")
|
||||
.WithPassword("postgres")
|
||||
.Build();
|
||||
|
||||
public IStorageService StorageService { get; } = Substitute.For<IStorageService>();
|
||||
|
||||
public IEventBus EventBus { get; } = Substitute.For<IEventBus>();
|
||||
|
||||
public IYouTubeMusicService YouTubeMusicService { get; } = Substitute.For<IYouTubeMusicService>();
|
||||
|
||||
public IStreamTicketService StreamTicketService { get; } = Substitute.For<IStreamTicketService>();
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
/// <summary>
|
||||
/// Boots the real <c>Musify.Api</c> host — real routing, real auth/authorization pipeline,
|
||||
/// real validators, real Mediator handlers — against a private Postgres container (so EF
|
||||
/// queries behave like production, not like a stand-in). The services this API talks to over
|
||||
/// the network (object storage, YouTube, the message bus, stream-ticket signing) are replaced
|
||||
/// with NSubstitute fakes: they are Infrastructure's job to verify against the real thing (see
|
||||
/// Musify.Infrastructure.Tests), not the HTTP layer's.
|
||||
/// </summary>
|
||||
public sealed class ApiTestFixture : WebApplicationFactory<Program>, IAsyncLifetime
|
||||
{
|
||||
await postgres.StartAsync();
|
||||
private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine")
|
||||
.WithDatabase("musify_api_test")
|
||||
.WithUsername("postgres")
|
||||
.WithPassword("postgres")
|
||||
.Build();
|
||||
|
||||
using var scope = Services.CreateScope();
|
||||
var database = scope.ServiceProvider.GetRequiredService<Musify.Infrastructure.Persistence.Database>();
|
||||
await database.Database.MigrateAsync();
|
||||
}
|
||||
public IStorageService StorageService { get; } = Substitute.For<IStorageService>();
|
||||
|
||||
// xUnit v3's IAsyncLifetime IS System.IAsyncDisposable (rather than declaring its own
|
||||
// Task-returning DisposeAsync as in v2), so there is a single DisposeAsync slot to fill —
|
||||
// override the one WebApplicationFactory already provides instead of implementing the
|
||||
// interface explicitly alongside it.
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
await base.DisposeAsync();
|
||||
await postgres.DisposeAsync();
|
||||
}
|
||||
public IEventBus EventBus { get; } = Substitute.For<IEventBus>();
|
||||
|
||||
/// <summary>An <see cref="HttpClient"/> with no auth header — every protected endpoint answers 401.</summary>
|
||||
public HttpClient CreateAnonymousClient() => CreateClient();
|
||||
public IYouTubeMusicService YouTubeMusicService { get; } = Substitute.For<IYouTubeMusicService>();
|
||||
|
||||
/// <summary>An <see cref="HttpClient"/> authenticated as the given user id (see <see cref="FakeAuthenticationHandler"/>).</summary>
|
||||
public HttpClient CreateAuthenticatedClient(long userId, string userName = "test-user")
|
||||
{
|
||||
var client = CreateClient();
|
||||
client.DefaultRequestHeaders.Add(FakeAuthenticationHandler.UserIdHeader, userId.ToString());
|
||||
client.DefaultRequestHeaders.Add("X-Test-User-Name", userName);
|
||||
return client;
|
||||
}
|
||||
public IStreamTicketService StreamTicketService { get; } = Substitute.For<IStreamTicketService>();
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
await postgres.StartAsync();
|
||||
|
||||
using var scope = Services.CreateScope();
|
||||
var database = scope.ServiceProvider.GetRequiredService<Musify.Infrastructure.Persistence.Database>();
|
||||
await database.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
// xUnit v3's IAsyncLifetime IS System.IAsyncDisposable (rather than declaring its own
|
||||
// Task-returning DisposeAsync as in v2), so there is a single DisposeAsync slot to fill —
|
||||
// override the one WebApplicationFactory already provides instead of implementing the
|
||||
// interface explicitly alongside it.
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
await base.DisposeAsync();
|
||||
await postgres.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>An <see cref="HttpClient"/> with no auth header — every protected endpoint answers 401.</summary>
|
||||
public HttpClient CreateAnonymousClient() => CreateClient();
|
||||
|
||||
/// <summary>An <see cref="HttpClient"/> authenticated as the given user id (see <see cref="FakeAuthenticationHandler"/>).</summary>
|
||||
public HttpClient CreateAuthenticatedClient(long userId, string userName = "test-user")
|
||||
{
|
||||
var client = CreateClient();
|
||||
client.DefaultRequestHeaders.Add(FakeAuthenticationHandler.UserIdHeader, userId.ToString());
|
||||
client.DefaultRequestHeaders.Add("X-Test-User-Name", userName);
|
||||
return client;
|
||||
}
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureAppConfiguration((_, configuration) =>
|
||||
{
|
||||
["Database:ConnectionString"] = postgres.GetConnectionString(),
|
||||
["Authentication:MetadataAddress"] = "https://auth.musify.test/.well-known/openid-configuration",
|
||||
["Authentication:IssuerAddress"] = "https://auth.musify.test",
|
||||
["Authentication:AudienceAddress"] = "musify-api-test",
|
||||
["Authentication:ClientId"] = "musify-api-test",
|
||||
["Authentication:AuthorizationEndpoint"] = "https://auth.musify.test/authorize",
|
||||
["Authentication:TokenEndpoint"] = "https://auth.musify.test/token",
|
||||
["Authentication:Scopes:0"] = "openid",
|
||||
["StreamTicket:PrivateKeyPath"] = "unused-in-tests.pem",
|
||||
["OpenTelemetry:OtlpEndpoint"] = "http://localhost:4317",
|
||||
["MassTransit:Address"] = "rabbitmq://localhost:5672",
|
||||
["MassTransit:Username"] = "guest",
|
||||
["MassTransit:Password"] = "guest",
|
||||
["InfrastructureStorage:Address"] = "http://localhost:8333",
|
||||
["InfrastructureStorage:AccessKey"] = "unused-in-tests",
|
||||
["InfrastructureStorage:SecretAccessKey"] = "unused-in-tests",
|
||||
["InfrastructureStorage:ForcePathStyle"] = "true",
|
||||
["InfrastructureStorage:UseHttp"] = "true",
|
||||
["ApplicationStorage:Bucket"] = "musify-api-test",
|
||||
["StreamGateway:PublicBaseUrl"] = "http://stream.musify.test",
|
||||
["PlayList:SeedPresetPictures"] = "false",
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Database:ConnectionString"] = postgres.GetConnectionString(),
|
||||
["Authentication:MetadataAddress"] = "https://auth.musify.test/.well-known/openid-configuration",
|
||||
["Authentication:IssuerAddress"] = "https://auth.musify.test",
|
||||
["Authentication:AudienceAddress"] = "musify-api-test",
|
||||
["Authentication:ClientId"] = "musify-api-test",
|
||||
["Authentication:AuthorizationEndpoint"] = "https://auth.musify.test/authorize",
|
||||
["Authentication:TokenEndpoint"] = "https://auth.musify.test/token",
|
||||
["Authentication:Scopes:0"] = "openid",
|
||||
["StreamTicket:PrivateKeyPath"] = "unused-in-tests.pem",
|
||||
["OpenTelemetry:OtlpEndpoint"] = "http://localhost:4317",
|
||||
["MassTransit:Address"] = "rabbitmq://localhost:5672",
|
||||
["MassTransit:Username"] = "guest",
|
||||
["MassTransit:Password"] = "guest",
|
||||
["InfrastructureStorage:Address"] = "http://localhost:8333",
|
||||
["InfrastructureStorage:AccessKey"] = "unused-in-tests",
|
||||
["InfrastructureStorage:SecretAccessKey"] = "unused-in-tests",
|
||||
["InfrastructureStorage:ForcePathStyle"] = "true",
|
||||
["InfrastructureStorage:UseHttp"] = "true",
|
||||
["ApplicationStorage:Bucket"] = "musify-api-test",
|
||||
["StreamGateway:PublicBaseUrl"] = "http://stream.musify.test",
|
||||
["PlayList:SeedPresetPictures"] = "false",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.Replace(ServiceDescriptor.Singleton(StorageService));
|
||||
services.Replace(ServiceDescriptor.Scoped(_ => EventBus));
|
||||
services.Replace(ServiceDescriptor.Singleton(YouTubeMusicService));
|
||||
services.Replace(ServiceDescriptor.Singleton(StreamTicketService));
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.Replace(ServiceDescriptor.Singleton(StorageService));
|
||||
services.Replace(ServiceDescriptor.Scoped(_ => EventBus));
|
||||
services.Replace(ServiceDescriptor.Singleton(YouTubeMusicService));
|
||||
services.Replace(ServiceDescriptor.Singleton(StreamTicketService));
|
||||
|
||||
// Overrides the real JwtBearer scheme as the default, so requests authenticate via
|
||||
// FakeAuthenticationHandler instead of needing a real token and a reachable IdP.
|
||||
services
|
||||
.AddAuthentication(FakeAuthenticationHandler.SchemeName)
|
||||
.AddScheme<AuthenticationSchemeOptions, FakeAuthenticationHandler>(FakeAuthenticationHandler.SchemeName, _ => { });
|
||||
});
|
||||
// Overrides the real JwtBearer scheme as the default, so requests authenticate via
|
||||
// FakeAuthenticationHandler instead of needing a real token and a reachable IdP.
|
||||
services
|
||||
.AddAuthentication(FakeAuthenticationHandler.SchemeName)
|
||||
.AddScheme<AuthenticationSchemeOptions, FakeAuthenticationHandler>(FakeAuthenticationHandler.SchemeName, _ => { });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class ApiCollection : ICollectionFixture<ApiTestFixture>
|
||||
{
|
||||
public const string Name = "Api";
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class ApiCollection : ICollectionFixture<ApiTestFixture>
|
||||
{
|
||||
public const string Name = "Api";
|
||||
}
|
||||
|
||||
@@ -4,34 +4,35 @@ using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Musify.Api.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the real JwtBearer handler in the test host: a request carrying the
|
||||
/// <see cref="UserIdHeader"/> header authenticates as that user id, with no real token,
|
||||
/// issuer or JWKS endpoint involved. A request without the header stays anonymous, so
|
||||
/// endpoints under <c>.RequireAuthorization()</c> correctly answer 401.
|
||||
/// </summary>
|
||||
public sealed class FakeAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
namespace Musify.Api.Tests.TestSupport
|
||||
{
|
||||
public const string SchemeName = "Test";
|
||||
public const string UserIdHeader = "X-Test-User-Id";
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
/// <summary>
|
||||
/// Replaces the real JwtBearer handler in the test host: a request carrying the
|
||||
/// <see cref="UserIdHeader"/> header authenticates as that user id, with no real token,
|
||||
/// issuer or JWKS endpoint involved. A request without the header stays anonymous, so
|
||||
/// endpoints under <c>.RequireAuthorization()</c> correctly answer 401.
|
||||
/// </summary>
|
||||
public sealed class FakeAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
if (!Request.Headers.TryGetValue(UserIdHeader, out var userId) || string.IsNullOrEmpty(userId))
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
public const string SchemeName = "Test";
|
||||
public const string UserIdHeader = "X-Test-User-Id";
|
||||
|
||||
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, userId!) };
|
||||
if (Request.Headers.TryGetValue("X-Test-User-Name", out var userName) && !string.IsNullOrEmpty(userName))
|
||||
claims.Add(new Claim(ClaimTypes.Name, userName!));
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue(UserIdHeader, out var userId) || string.IsNullOrEmpty(userId))
|
||||
return Task.FromResult(AuthenticateResult.NoResult());
|
||||
|
||||
var identity = new ClaimsIdentity(claims, SchemeName);
|
||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, userId!) };
|
||||
if (Request.Headers.TryGetValue("X-Test-User-Name", out var userName) && !string.IsNullOrEmpty(userName))
|
||||
claims.Add(new Claim(ClaimTypes.Name, userName!));
|
||||
|
||||
var identity = new ClaimsIdentity(claims, SchemeName);
|
||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName);
|
||||
return Task.FromResult(AuthenticateResult.Success(ticket));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user