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:
@@ -0,0 +1,84 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Musify.Api.Authentication;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Api.Tests.Authentication;
|
||||
|
||||
public sealed class CurrentUserTests
|
||||
{
|
||||
private static async Task<CurrentUser> BindAsync(ClaimsPrincipal principal)
|
||||
{
|
||||
var context = new DefaultHttpContext { User = principal };
|
||||
var bound = await CurrentUser.BindAsync(context, parameter: null!);
|
||||
return bound!;
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal AuthenticatedPrincipal(params Claim[] claims) =>
|
||||
new(new ClaimsIdentity(claims, authenticationType: "TestAuth"));
|
||||
|
||||
[Fact]
|
||||
public async Task BindAsync_AuthenticatedPrincipalWithValidClaims_PopulatesEveryField()
|
||||
{
|
||||
var principal = AuthenticatedPrincipal(
|
||||
new Claim(ClaimTypes.NameIdentifier, "42"),
|
||||
new Claim(ClaimTypes.Name, "jdoe"),
|
||||
new Claim(ClaimTypes.GivenName, "Jane"),
|
||||
new Claim(ClaimTypes.Surname, "Doe"));
|
||||
|
||||
var user = await BindAsync(principal);
|
||||
|
||||
Assert.True(user.IsAuthenticated);
|
||||
Assert.Equal(42, user.Id);
|
||||
Assert.Equal(42, user.RequiredId);
|
||||
Assert.Equal("jdoe", user.Username);
|
||||
Assert.Equal("Jane", user.FirstName);
|
||||
Assert.Equal("Doe", user.LastName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BindAsync_AnonymousPrincipal_IsNotAuthenticatedAndHasNoId()
|
||||
{
|
||||
var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||
|
||||
Assert.False(user.IsAuthenticated);
|
||||
Assert.Null(user.Id);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-a-number")]
|
||||
[InlineData("0")]
|
||||
[InlineData("")]
|
||||
public async Task BindAsync_NonNumericOrZeroNameIdentifier_LeavesIdNull(string rawId)
|
||||
{
|
||||
var user = await BindAsync(AuthenticatedPrincipal(new Claim(ClaimTypes.NameIdentifier, rawId)));
|
||||
|
||||
Assert.Null(user.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequiredId_MissingId_Throws()
|
||||
{
|
||||
var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => user.RequiredId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasClaim_ExistingClaimType_ReturnsTrue()
|
||||
{
|
||||
var user = await BindAsync(AuthenticatedPrincipal(new Claim("role", "admin")));
|
||||
|
||||
Assert.True(user.HasClaim("role"));
|
||||
Assert.False(user.HasClaim("missing"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindClaim_ExistingClaimType_ReturnsItsValue()
|
||||
{
|
||||
var user = await BindAsync(AuthenticatedPrincipal(new Claim("picture", "https://avatar.example/jane.png")));
|
||||
|
||||
Assert.Equal("https://avatar.example/jane.png", user.FindClaim("picture"));
|
||||
Assert.Null(user.FindClaim("missing"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Musify.Api.DataTransferObjects.Albums;
|
||||
using Musify.Api.DataTransferObjects.Users;
|
||||
using Musify.Api.Tests.TestSupport;
|
||||
using Musify.Application.Albums.Responses;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Api.Tests.Endpoints;
|
||||
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class AlbumEndpointsTests(ApiTestFixture fixture)
|
||||
{
|
||||
private async Task<long> CreateUserAsync()
|
||||
{
|
||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
||||
var response = await fixture.CreateAnonymousClient()
|
||||
.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null));
|
||||
response.EnsureSuccessStatusCode();
|
||||
return userId;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostAlbums_NoAuthHeader_ReturnsUnauthorized()
|
||||
{
|
||||
var response = await fixture.CreateAnonymousClient()
|
||||
.PostAsJsonAsync("/albums", new CreateAlbumRequest("Unauthorized Album", null, null));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostAlbums_AuthenticatedExistingUser_ReturnsCreatedAlbum()
|
||||
{
|
||||
var userId = await CreateUserAsync();
|
||||
var client = fixture.CreateAuthenticatedClient(userId);
|
||||
|
||||
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<AlbumApplicationResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal("My Album", body.Title);
|
||||
Assert.Equal(userId, body.OwnerUserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostAlbums_BlankTitle_ReturnsValidationProblem()
|
||||
{
|
||||
var userId = await CreateUserAsync();
|
||||
var client = fixture.CreateAuthenticatedClient(userId);
|
||||
|
||||
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("", null, null));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAlbumById_AfterCreate_ReturnsIt()
|
||||
{
|
||||
var userId = await CreateUserAsync();
|
||||
var client = fixture.CreateAuthenticatedClient(userId);
|
||||
var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Findable", null, null)))
|
||||
.Content.ReadFromJsonAsync<AlbumApplicationResponse>();
|
||||
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAlbum_NotTheOwner_ReturnsUnauthorized()
|
||||
{
|
||||
var owner = await CreateUserAsync();
|
||||
var stranger = await CreateUserAsync();
|
||||
var created = await (await fixture.CreateAuthenticatedClient(owner).PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null)))
|
||||
.Content.ReadFromJsonAsync<AlbumApplicationResponse>();
|
||||
|
||||
var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAlbum_Owner_ReturnsNoContentAndAlbumIsGone()
|
||||
{
|
||||
var userId = await CreateUserAsync();
|
||||
var client = fixture.CreateAuthenticatedClient(userId);
|
||||
var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Deletable", null, null)))
|
||||
.Content.ReadFromJsonAsync<AlbumApplicationResponse>();
|
||||
|
||||
var deleteResponse = await client.DeleteAsync($"/albums/{created!.Id}");
|
||||
var getResponse = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created.Id}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Musify.Api.DataTransferObjects.Users;
|
||||
using Musify.Api.Tests.TestSupport;
|
||||
using Musify.Application.Users.Responses;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Api.Tests.Endpoints;
|
||||
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class UserEndpointsTests(ApiTestFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task PostUsers_NewUser_ReturnsCreatedWithLocationHeader()
|
||||
{
|
||||
var client = fixture.CreateAnonymousClient();
|
||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
||||
|
||||
var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Jane Doe", "Jane", "Doe"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
Assert.Equal($"/users/{userId}", response.Headers.Location?.OriginalString);
|
||||
var body = await response.Content.ReadFromJsonAsync<UserApplicationResponse>();
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal("Jane Doe", body.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostUsers_BlankName_ReturnsValidationProblem()
|
||||
{
|
||||
var client = fixture.CreateAnonymousClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(Random.Shared.NextInt64(1, long.MaxValue), "", null, null));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostUsers_DuplicateId_ReturnsConflict()
|
||||
{
|
||||
var client = fixture.CreateAnonymousClient();
|
||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
||||
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "First", null, null));
|
||||
|
||||
var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Second", null, null));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUserById_ExistingUser_ReturnsIt()
|
||||
{
|
||||
var client = fixture.CreateAnonymousClient();
|
||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
||||
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Findable User", null, null));
|
||||
|
||||
var response = await client.GetAsync($"/users/{userId}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<UserApplicationResponse>();
|
||||
Assert.Equal("Findable User", body?.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUserById_MissingUser_ReturnsNotFound()
|
||||
{
|
||||
var client = fixture.CreateAnonymousClient();
|
||||
|
||||
var response = await client.GetAsync($"/users/{Random.Shared.NextInt64(1, long.MaxValue)}");
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using ErrorOr;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Musify.Api.Extensions;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Api.Tests.Extensions;
|
||||
|
||||
public sealed class ErrorOrHttpExtensionsTests
|
||||
{
|
||||
// The concrete IResult types (NoContent, UnauthorizedHttpResult, ...) resolve logging/problem-
|
||||
// details services from HttpContext.RequestServices when they execute, so it can't be empty.
|
||||
private static readonly IServiceProvider Services = new ServiceCollection().AddLogging().AddProblemDetails().BuildServiceProvider();
|
||||
|
||||
private static async Task<(int StatusCode, string Body)> ExecuteAsync(IResult result)
|
||||
{
|
||||
var context = new DefaultHttpContext { RequestServices = Services };
|
||||
var bodyStream = new MemoryStream();
|
||||
context.Response.Body = bodyStream;
|
||||
|
||||
await result.ExecuteAsync(context);
|
||||
|
||||
bodyStream.Position = 0;
|
||||
using var reader = new StreamReader(bodyStream);
|
||||
return (context.Response.StatusCode, await reader.ReadToEndAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToHttpResult_Success_ReturnsNoContent()
|
||||
{
|
||||
ErrorOr<Success> result = new Success();
|
||||
|
||||
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
|
||||
|
||||
Assert.Equal(StatusCodes.Status204NoContent, statusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToHttpResult_Value_ReturnsOkWithTheValue()
|
||||
{
|
||||
ErrorOr<string> result = "hello";
|
||||
|
||||
var (statusCode, body) = await ExecuteAsync(result.ToHttpResult());
|
||||
|
||||
Assert.Equal(StatusCodes.Status200OK, statusCode);
|
||||
Assert.Contains("hello", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToHttpResult_NotFoundError_ReturnsNotFound()
|
||||
{
|
||||
ErrorOr<string> result = Error.NotFound(description: "not here");
|
||||
|
||||
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
|
||||
|
||||
Assert.Equal(StatusCodes.Status404NotFound, statusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToHttpResult_UnauthorizedError_ReturnsUnauthorized()
|
||||
{
|
||||
ErrorOr<string> result = Error.Unauthorized();
|
||||
|
||||
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
|
||||
|
||||
Assert.Equal(StatusCodes.Status401Unauthorized, statusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToHttpResult_ForbiddenError_ReturnsForbidden()
|
||||
{
|
||||
ErrorOr<string> result = Error.Forbidden();
|
||||
|
||||
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
|
||||
|
||||
Assert.Equal(StatusCodes.Status403Forbidden, statusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToHttpResult_ConflictError_ReturnsConflict()
|
||||
{
|
||||
ErrorOr<string> result = Error.Conflict();
|
||||
|
||||
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
|
||||
|
||||
Assert.Equal(StatusCodes.Status409Conflict, statusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToHttpResult_FailureError_ReturnsProblem500()
|
||||
{
|
||||
ErrorOr<string> result = Error.Failure();
|
||||
|
||||
var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
|
||||
|
||||
Assert.Equal(StatusCodes.Status500InternalServerError, statusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToHttpResult_ValidationErrors_ReturnsValidationProblem()
|
||||
{
|
||||
ErrorOr<string> result = new List<Error>
|
||||
{
|
||||
Error.Validation("Name", "Name is required"),
|
||||
Error.Validation("Name", "Name is too long"),
|
||||
Error.Validation("Age", "Age must be positive"),
|
||||
};
|
||||
|
||||
var (statusCode, body) = await ExecuteAsync(result.ToHttpResult());
|
||||
|
||||
Assert.Equal(StatusCodes.Status400BadRequest, statusCode);
|
||||
Assert.Contains("Name is required", body);
|
||||
Assert.Contains("Age must be positive", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToCreatedResult_Success_ReturnsCreatedAtTheGivenLocation()
|
||||
{
|
||||
ErrorOr<string> result = "the-id";
|
||||
|
||||
var context = new DefaultHttpContext { RequestServices = Services, Response = { Body = new MemoryStream() } };
|
||||
await result.ToCreatedResult(value => $"/things/{value}").ExecuteAsync(context);
|
||||
|
||||
Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode);
|
||||
Assert.Equal("/things/the-id", context.Response.Headers.Location);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ToCreatedResult_Error_ReturnsTheMappedErrorStatus()
|
||||
{
|
||||
ErrorOr<string> result = Error.Conflict();
|
||||
|
||||
var (statusCode, _) = await ExecuteAsync(result.ToCreatedResult(value => $"/things/{value}"));
|
||||
|
||||
Assert.Equal(StatusCodes.Status409Conflict, statusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Musify.Api.Filters;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Api.Tests.Filters;
|
||||
|
||||
public sealed class ValidationFilterTests
|
||||
{
|
||||
public sealed record Payload(string Name);
|
||||
|
||||
// EndpointFilterInvocationContext.Create has no (HttpContext, object[]) overload — only
|
||||
// Create<T0>, Create<T0,T1>, etc. Passing an object[] to a params-object[] call site binds
|
||||
// to Create<T0> with T0 inferred as object[] itself, so .Arguments ends up with one element
|
||||
// (the array, boxed) instead of the array's contents — OfType<Payload>() then never matches
|
||||
// and the filter silently falls through to "no argument, skip validation" for every case.
|
||||
private static EndpointFilterInvocationContext CreateContext<T>(T argument) =>
|
||||
EndpointFilterInvocationContext.Create(new DefaultHttpContext(), argument);
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ValidPayload_CallsNext()
|
||||
{
|
||||
var validator = Substitute.For<IValidator<Payload>>();
|
||||
validator.ValidateAsync(Arg.Any<Payload>(), Arg.Any<CancellationToken>()).Returns(new ValidationResult());
|
||||
var filter = new ValidationFilter<Payload>(validator);
|
||||
var context = CreateContext(new Payload("ok"));
|
||||
|
||||
var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult<object?>("next-called"));
|
||||
|
||||
Assert.Equal("next-called", result);
|
||||
await validator.Received(1).ValidateAsync(Arg.Is<Payload>(p => p.Name == "ok"), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_InvalidPayload_ReturnsValidationProblemWithoutCallingNext()
|
||||
{
|
||||
var validator = Substitute.For<IValidator<Payload>>();
|
||||
validator.ValidateAsync(Arg.Any<Payload>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new ValidationResult([new ValidationFailure("Name", "Name is required")]));
|
||||
var filter = new ValidationFilter<Payload>(validator);
|
||||
var context = CreateContext(new Payload(""));
|
||||
var nextCalled = false;
|
||||
|
||||
var result = await filter.InvokeAsync(context, _ => { nextCalled = true; return ValueTask.FromResult<object?>(null); });
|
||||
|
||||
Assert.False(nextCalled);
|
||||
var problem = Assert.IsType<ProblemHttpResult>(result);
|
||||
Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_NoMatchingArgument_CallsNextWithoutValidating()
|
||||
{
|
||||
var validator = Substitute.For<IValidator<Payload>>();
|
||||
var filter = new ValidationFilter<Payload>(validator);
|
||||
var context = CreateContext(42);
|
||||
|
||||
var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult<object?>("next-called"));
|
||||
|
||||
Assert.Equal("next-called", result);
|
||||
await validator.DidNotReceiveWithAnyArgs().ValidateAsync(Arg.Any<Payload>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\Musify.Domain\Musify.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\Core\Musify.Application\Musify.Application.csproj" />
|
||||
<ProjectReference Include="..\..\Core\Musify.Infrastructure\Musify.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\Hosts\Musify.Api\Musify.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Musify.Api.DataTransferObjects.Albums;
|
||||
using Musify.Api.Validators.Albums;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Api.Tests.Validators;
|
||||
|
||||
public sealed class CreateAlbumRequestValidatorTests
|
||||
{
|
||||
private readonly CreateAlbumRequestValidator validator = new();
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidRequest_Passes()
|
||||
{
|
||||
var result = validator.Validate(new CreateAlbumRequest("My Album", "A short description", 2024));
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidRequestWithoutOptionalFields_Passes()
|
||||
{
|
||||
var result = validator.Validate(new CreateAlbumRequest("My Album", null, null));
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_BlankTitle_Fails()
|
||||
{
|
||||
var result = validator.Validate(new CreateAlbumRequest("", null, null));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.Title));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_TitleOverMaxLength_Fails()
|
||||
{
|
||||
var result = validator.Validate(new CreateAlbumRequest(new string('a', 201), null, null));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1876)]
|
||||
[InlineData(1877)]
|
||||
public void Validate_ReleaseYearAtOrBeforeTheEarliestBoundary(int year)
|
||||
{
|
||||
var result = validator.Validate(new CreateAlbumRequest("Title", null, year));
|
||||
|
||||
Assert.Equal(year >= 1877, result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ReleaseYearMoreThanOneYearInTheFuture_Fails()
|
||||
{
|
||||
var result = validator.Validate(new CreateAlbumRequest("Title", null, DateTime.UtcNow.Year + 2));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Musify.Api.DataTransferObjects.Users;
|
||||
using Musify.Api.Validators.Users;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Api.Tests.Validators;
|
||||
|
||||
public sealed class CreateUserRequestValidatorTests
|
||||
{
|
||||
private readonly CreateUserRequestValidator validator = new();
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidRequest_Passes()
|
||||
{
|
||||
var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", "Jane", "Doe"));
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ZeroId_Fails()
|
||||
{
|
||||
var result = validator.Validate(new CreateUserRequest(0, "Jane Doe", null, null));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateUserRequest.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_BlankName_Fails()
|
||||
{
|
||||
var result = validator.Validate(new CreateUserRequest(1, "", null, null));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NameOverMaxLength_Fails()
|
||||
{
|
||||
var result = validator.Validate(new CreateUserRequest(1, new string('a', 49), null, null));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_OptionalNamesOmitted_Passes()
|
||||
{
|
||||
var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", null, null));
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user