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).
118 lines
5.4 KiB
C#
118 lines
5.4 KiB
C#
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";
|
|
}
|