Add Infrastructure/Api/StreamingGateway test projects

Second pass on backend/Tests/, covering what Domain+Application (the first
pass) deliberately left out — the parts that talk to real infrastructure:

  Musify.Infrastructure.Tests   Database/migrations, StorageService,
                                 StreamTicketService, AuditableEntityInterceptor,
                                 PictureService, SingleFlightCache,
                                 PlayListPresetSeeder.
  Musify.Api.Tests               Real HTTP endpoints via WebApplicationFactory
                                 (routing, auth, ValidationFilter, ErrorOr->HTTP
                                 mapping), plus isolated unit tests for
                                 ErrorOrHttpExtensions, CurrentUser and the
                                 FluentValidation validators.
  Musify.StreamingGateway.Tests  TicketValidator (RS256) and
                                 TicketValidationMiddleware (path traversal,
                                 prefix boundary, ticket extraction).

Musify.Worker gets no test project: its Program.cs is pure DI/host wiring
(Hangfire, MassTransit, jobs); the logic it runs lives in Musify.Infrastructure,
already covered there.

Per the user's direction, the parts that touch real infrastructure use
Testcontainers instead of fakes: a real Postgres (Testcontainers.PostgreSql)
for Infrastructure.Tests and Api.Tests, and — matching deploy/compose.yml
exactly (chrislusf/seaweedfs:latest, same startup command), not MinIO — a
real SeaweedFS container for StorageService. Api.Tests boots the actual
Musify.Api host against that Postgres (real routing/auth/validation) but
substitutes the services that talk over the network (storage, YouTube, the
message bus, stream-ticket signing) with NSubstitute fakes, and swaps the
JwtBearer scheme for a test-only header-based one (FakeAuthenticationHandler)
so tests don't need a reachable Zitadel. Application.Tests' approach — a real
DbContext (SQLite in-memory) rather than mocking IDatabase, since it exposes
DbSet<T> directly and handlers compose real LINQ queries against it — carries
over into Infrastructure.Tests/Api.Tests via real Postgres instead.

Testing against the real backend (not a fake) found two genuine production
bugs, both fixed here:

- StorageService.RemoveFolderAsync threw NullReferenceException against
  SeaweedFS: its DeleteObjects response omits the <Error> list entirely when
  nothing failed, which the AWS SDK deserializes as null rather than an empty
  list (real AWS S3 always returns one). Now treats null as no errors.
- AuditableEntityInterceptor never bumped UpdatedAt on a normal update (load,
  mutate a property, SaveChanges) — only on insert. SavingChanges(Async) fires
  before EF's own automatic change detection for that save, so an entity
  modified through a plain property setter still reported EntityState.Unchanged
  inside the interceptor. Now calls ChangeTracker.DetectChanges() explicitly
  first, which is the documented fix for exactly this interceptor pattern.

Also required to make the Api host testable: Program.cs now ends with `public
partial class Program;` (WebApplicationFactory<Program> needs the top-level-
statements Program class to be public — no behavior change), and
Directory.Packages.props gains Microsoft.AspNetCore.Mvc.Testing, Testcontainers
and Testcontainers.PostgreSql.

249 tests total across all five projects, all green: `dotnet test
backend/Musify.slnx` (needs Docker running for Infrastructure.Tests and
Api.Tests).
This commit is contained in:
2026-09-09 21:39:06 +02:00
parent 58681b5ac4
commit a26f4b43fd
30 changed files with 1908 additions and 14 deletions
@@ -0,0 +1,117 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Musify.Application.Contracts;
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
{
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 Task InitializeAsync()
{
await postgres.StartAsync();
using var scope = Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Musify.Infrastructure.Persistence.Database>();
await database.Database.MigrateAsync();
}
async Task IAsyncLifetime.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) =>
{
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));
// 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";
}
@@ -0,0 +1,37 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
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)
{
public const string SchemeName = "Test";
public const string UserIdHeader = "X-Test-User-Id";
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue(UserIdHeader, out var userId) || string.IsNullOrEmpty(userId))
return Task.FromResult(AuthenticateResult.NoResult());
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));
}
}