Design Prot Merge #1
@@ -12,7 +12,7 @@ docs/ What each piece does and why (in Spanish)
|
|||||||
```
|
```
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
dotnet test backend/Musify.slnx # backend/Tests/ — no Docker, no database
|
dotnet test backend/Musify.slnx # backend/Tests/ — Docker running (Testcontainers: Postgres, SeaweedFS)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run it
|
## Run it
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ namespace Musify.Infrastructure.Persistence
|
|||||||
if (context is null)
|
if (context is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// SavingChanges(Async) fires before EF's own automatic change detection for this
|
||||||
|
// save, so entities modified through plain property assignment (query, then set a
|
||||||
|
// property, then SaveChanges — the normal case) still show as Unchanged here unless
|
||||||
|
// something already forced detection. Force it explicitly, otherwise UpdatedAt is
|
||||||
|
// never bumped on a real update, only on inserts.
|
||||||
|
context.ChangeTracker.DetectChanges();
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
foreach (var entry in context.ChangeTracker.Entries<IAuditable>())
|
foreach (var entry in context.ChangeTracker.Entries<IAuditable>())
|
||||||
|
|||||||
@@ -289,9 +289,13 @@ namespace Musify.Infrastructure.Services
|
|||||||
|
|
||||||
var deleteResponse = await amazonS3.DeleteObjectsAsync(deleteRequest, cancellationToken);
|
var deleteResponse = await amazonS3.DeleteObjectsAsync(deleteRequest, cancellationToken);
|
||||||
|
|
||||||
if (deleteResponse.DeleteErrors.Count > 0)
|
// Some S3-compatible backends (SeaweedFS included) omit the <Error> list from the
|
||||||
|
// XML response entirely when nothing failed, which the SDK deserializes as null
|
||||||
|
// rather than an empty list — real AWS S3 always returns a (possibly empty) list.
|
||||||
|
var deleteErrors = deleteResponse.DeleteErrors ?? [];
|
||||||
|
if (deleteErrors.Count > 0)
|
||||||
{
|
{
|
||||||
var errors = string.Join(", ", deleteResponse.DeleteErrors.Select(e => $"{e.Key}: {e.Message}"));
|
var errors = string.Join(", ", deleteErrors.Select(e => $"{e.Key}: {e.Message}"));
|
||||||
throw new InvalidOperationException($"Failed to delete some objects in folder {folderKey}: {errors}");
|
throw new InvalidOperationException($"Failed to delete some objects in folder {folderKey}: {errors}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,9 @@
|
|||||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.2" />
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.2" />
|
||||||
|
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
|
||||||
|
<PackageVersion Include="Testcontainers" Version="4.15.0" />
|
||||||
|
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.15.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -45,4 +45,8 @@ app
|
|||||||
.MapUserEndpoints()
|
.MapUserEndpoints()
|
||||||
.MapYouTubeEndpoints();
|
.MapYouTubeEndpoints();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
// Makes the top-level-statements Program class public so
|
||||||
|
// WebApplicationFactory<Program> (Musify.Api.Tests) can see it.
|
||||||
|
public partial class Program;
|
||||||
@@ -12,5 +12,8 @@
|
|||||||
<Folder Name="/Tests/">
|
<Folder Name="/Tests/">
|
||||||
<Project Path="Tests/Musify.Domain.Tests/Musify.Domain.Tests.csproj" />
|
<Project Path="Tests/Musify.Domain.Tests/Musify.Domain.Tests.csproj" />
|
||||||
<Project Path="Tests/Musify.Application.Tests/Musify.Application.Tests.csproj" />
|
<Project Path="Tests/Musify.Application.Tests/Musify.Application.Tests.csproj" />
|
||||||
|
<Project Path="Tests/Musify.Infrastructure.Tests/Musify.Infrastructure.Tests.csproj" />
|
||||||
|
<Project Path="Tests/Musify.Api.Tests/Musify.Api.Tests.csproj" />
|
||||||
|
<Project Path="Tests/Musify.StreamingGateway.Tests/Musify.StreamingGateway.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Testcontainers.PostgreSql pulls an older EFCore.Relational transitively;
|
||||||
|
pin it explicitly so it doesn't win over the 10.0.8 the rest of the
|
||||||
|
solution (and Musify.Infrastructure itself) is built against. -->
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="NSubstitute" />
|
||||||
|
<PackageReference Include="Testcontainers" />
|
||||||
|
<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" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Infrastructure.Tests.TestSupport;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Musify.Infrastructure.Tests.Persistence;
|
||||||
|
|
||||||
|
[Collection(InfrastructureCollection.Name)]
|
||||||
|
public sealed class AuditableEntityInterceptorTests(InfrastructureTestFixture fixture)
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task SavingChanges_NewEntity_SetsCreatedAtAndUpdatedAtToNow()
|
||||||
|
{
|
||||||
|
await using var database = fixture.CreateDatabase();
|
||||||
|
var before = DateTime.UtcNow;
|
||||||
|
|
||||||
|
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "audit-user", NormalizedName = "AUDIT-USER" };
|
||||||
|
await database.Users.AddAsync(user);
|
||||||
|
await database.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var after = DateTime.UtcNow;
|
||||||
|
|
||||||
|
Assert.InRange(user.CreatedAt, before, after);
|
||||||
|
Assert.InRange(user.UpdatedAt, before, after);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SavingChanges_ModifiedEntity_BumpsUpdatedAtButKeepsCreatedAt()
|
||||||
|
{
|
||||||
|
await using var database = fixture.CreateDatabase();
|
||||||
|
|
||||||
|
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "audit-user-2", NormalizedName = "AUDIT-USER-2" };
|
||||||
|
await database.Users.AddAsync(user);
|
||||||
|
await database.SaveChangesAsync(CancellationToken.None);
|
||||||
|
var originalCreatedAt = user.CreatedAt;
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(50));
|
||||||
|
|
||||||
|
user.Name = "audit-user-2-renamed";
|
||||||
|
await database.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(originalCreatedAt, user.CreatedAt);
|
||||||
|
Assert.True(user.UpdatedAt > originalCreatedAt,
|
||||||
|
$"original-created-at={originalCreatedAt:O}, final-updated-at={user.UpdatedAt:O}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
using Musify.Infrastructure.Tests.TestSupport;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Musify.Infrastructure.Tests.Persistence;
|
||||||
|
|
||||||
|
[Collection(InfrastructureCollection.Name)]
|
||||||
|
public sealed class DatabaseTests(InfrastructureTestFixture fixture)
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveChangesAsync_LocalTrackWithOwnedTypes_RoundTripsThroughPostgres()
|
||||||
|
{
|
||||||
|
await using var database = fixture.CreateDatabase();
|
||||||
|
|
||||||
|
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "roundtrip-user", NormalizedName = "ROUNDTRIP-USER" };
|
||||||
|
await database.Users.AddAsync(user);
|
||||||
|
|
||||||
|
var track = new LocalTrack
|
||||||
|
{
|
||||||
|
Title = "Round Trip Track",
|
||||||
|
NormalizedTitle = "ROUND TRIP TRACK",
|
||||||
|
DurationSeconds = 123.4,
|
||||||
|
OwnerUserId = user.Id,
|
||||||
|
Owner = user,
|
||||||
|
Pictures = new TrackPictures { OriginalName = "cover.webp", ProcessingStatus = ProcessingStatus.Pending },
|
||||||
|
Audio = new TrackAudio { OriginalName = "song.mp3", TranscodeStatus = ProcessingStatus.Pending }
|
||||||
|
};
|
||||||
|
await database.LocalTracks.AddAsync(track);
|
||||||
|
await database.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await using var reloaded = fixture.CreateDatabase();
|
||||||
|
var stored = await reloaded.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id);
|
||||||
|
|
||||||
|
Assert.IsType<LocalTrack>(stored);
|
||||||
|
Assert.Equal("Round Trip Track", stored.Title);
|
||||||
|
Assert.Equal("cover.webp", stored.Pictures.OriginalName);
|
||||||
|
Assert.Equal("song.mp3", stored.Audio.OriginalName);
|
||||||
|
Assert.Equal(user.Id, ((LocalTrack)stored).OwnerUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExternalTracks_SourceAndExternalIdUniqueIndex_RejectsDuplicates()
|
||||||
|
{
|
||||||
|
await using var database = fixture.CreateDatabase();
|
||||||
|
|
||||||
|
var videoId = $"dup-{Guid.NewGuid():N}";
|
||||||
|
await database.ExternalTracks.AddAsync(BuildExternalTrack(videoId));
|
||||||
|
await database.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await using var second = fixture.CreateDatabase();
|
||||||
|
await second.ExternalTracks.AddAsync(BuildExternalTrack(videoId));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<DbUpdateException>(() => second.SaveChangesAsync(CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AlbumHasTracks_DeletingAlbum_CascadesToTheLinkRows()
|
||||||
|
{
|
||||||
|
await using var database = fixture.CreateDatabase();
|
||||||
|
|
||||||
|
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "cascade-user", NormalizedName = "CASCADE-USER" };
|
||||||
|
var album = new UserAlbum { Title = "Cascade Album", NormalizedTitle = "CASCADE ALBUM", OwnerUserId = user.Id };
|
||||||
|
var track = BuildExternalTrack($"cascade-{Guid.NewGuid():N}");
|
||||||
|
var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 };
|
||||||
|
|
||||||
|
await database.AddRangeAsync(user, album, track, link);
|
||||||
|
await database.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await using var deleter = fixture.CreateDatabase();
|
||||||
|
var albumToDelete = await deleter.UserAlbums.SingleAsync(a => a.Id == album.Id);
|
||||||
|
deleter.UserAlbums.Remove(albumToDelete);
|
||||||
|
await deleter.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
await using var verifier = fixture.CreateDatabase();
|
||||||
|
Assert.False(await verifier.AlbumHasTracks.AnyAsync(l => l.AlbumId == album.Id));
|
||||||
|
Assert.True(await verifier.ExternalTracks.AnyAsync(t => t.Id == track.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExternalTrack BuildExternalTrack(string externalId) => new()
|
||||||
|
{
|
||||||
|
Title = "External",
|
||||||
|
NormalizedTitle = "EXTERNAL",
|
||||||
|
DurationSeconds = 100,
|
||||||
|
Source = TrackSource.YouTube,
|
||||||
|
ExternalId = externalId,
|
||||||
|
Pictures = new TrackPictures(),
|
||||||
|
Audio = new TrackAudio()
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Musify.Infrastructure.Services;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Musify.Infrastructure.Tests.Services;
|
||||||
|
|
||||||
|
public sealed class PictureServiceTests
|
||||||
|
{
|
||||||
|
private static PictureService CreateService() => new(NullLogger<PictureService>.Instance);
|
||||||
|
|
||||||
|
private static MemoryStream CreateSourceImage(int width, int height)
|
||||||
|
{
|
||||||
|
using var image = new Image<Rgba32>(width, height, new Rgba32(255, 0, 0));
|
||||||
|
var stream = new MemoryStream();
|
||||||
|
image.SaveAsPng(stream);
|
||||||
|
stream.Position = 0;
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResizePictureAsWebpAsync_LargerThanTarget_ShrinksToFitWithinTheRequestedBox()
|
||||||
|
{
|
||||||
|
using var source = CreateSourceImage(800, 600);
|
||||||
|
|
||||||
|
await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 200, 200, CancellationToken.None);
|
||||||
|
|
||||||
|
using var result = await Image.LoadAsync(resized);
|
||||||
|
Assert.True(result.Width <= 200);
|
||||||
|
Assert.True(result.Height <= 200);
|
||||||
|
Assert.True(result.Width == 200 || result.Height == 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResizePictureAsWebpAsync_ReturnsAValidWebpStream()
|
||||||
|
{
|
||||||
|
using var source = CreateSourceImage(100, 100);
|
||||||
|
|
||||||
|
await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 50, 50, CancellationToken.None);
|
||||||
|
|
||||||
|
var format = await Image.DetectFormatAsync(resized);
|
||||||
|
Assert.Equal("WEBP", format?.Name, ignoreCase: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResizePictureAsWebpAsync_PreservesAspectRatio()
|
||||||
|
{
|
||||||
|
using var source = CreateSourceImage(400, 200);
|
||||||
|
|
||||||
|
await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 100, 100, CancellationToken.None);
|
||||||
|
|
||||||
|
using var result = await Image.LoadAsync(resized);
|
||||||
|
Assert.Equal(100, result.Width);
|
||||||
|
Assert.Equal(50, result.Height);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Infrastructure.Configuration;
|
||||||
|
using Musify.Infrastructure.Services;
|
||||||
|
using Musify.Infrastructure.Tests.TestSupport;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Musify.Infrastructure.Tests.Services;
|
||||||
|
|
||||||
|
[Collection(InfrastructureCollection.Name)]
|
||||||
|
public sealed class PlayListPresetSeederTests(InfrastructureTestFixture fixture)
|
||||||
|
{
|
||||||
|
private static PlayListConfiguration NewConfiguration(bool overwritePresetPictures) => new()
|
||||||
|
{
|
||||||
|
Routes = new PlayListRoutes { ParentFolders = $"PlayLists-{Guid.NewGuid():N}" },
|
||||||
|
SeedPresetPictures = true,
|
||||||
|
OverwritePresetPictures = overwritePresetPictures
|
||||||
|
};
|
||||||
|
|
||||||
|
private (PlayListPresetSeeder Seeder, IStorageService Storage) Build(PlayListConfiguration configuration)
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddSingleton(configuration);
|
||||||
|
services.AddSingleton(new ApplicationStorageConfiguration { Bucket = InfrastructureTestFixture.S3Bucket });
|
||||||
|
services.AddSingleton<IStorageService>(_ => new StorageService(
|
||||||
|
fixture.CreateDatabase(),
|
||||||
|
fixture.CreateS3Client(),
|
||||||
|
NullLogger<StorageService>.Instance,
|
||||||
|
new InfrastructureStorageConfiguration
|
||||||
|
{
|
||||||
|
Address = fixture.S3ServiceUrl,
|
||||||
|
AccessKey = InfrastructureTestFixture.S3AccessKey,
|
||||||
|
SecretAccessKey = InfrastructureTestFixture.S3SecretKey,
|
||||||
|
ForcePathStyle = true,
|
||||||
|
UseHttp = true,
|
||||||
|
}));
|
||||||
|
services.AddSingleton<IPictureService>(new PictureService(NullLogger<PictureService>.Instance));
|
||||||
|
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
var seeder = new PlayListPresetSeeder(provider.GetRequiredService<IServiceScopeFactory>(), NullLogger<PlayListPresetSeeder>.Instance);
|
||||||
|
return (seeder, provider.GetRequiredService<IStorageService>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task RunToCompletionAsync(PlayListPresetSeeder seeder)
|
||||||
|
{
|
||||||
|
await seeder.StartAsync(CancellationToken.None);
|
||||||
|
await (seeder.ExecuteTask ?? Task.CompletedTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_SeedsAllFourPresetPictures()
|
||||||
|
{
|
||||||
|
var configuration = NewConfiguration(overwritePresetPictures: false);
|
||||||
|
var (seeder, storage) = Build(configuration);
|
||||||
|
|
||||||
|
await RunToCompletionAsync(seeder);
|
||||||
|
|
||||||
|
var routes = configuration.Routes;
|
||||||
|
foreach (var key in new[]
|
||||||
|
{
|
||||||
|
routes.BuildOriginalPicturePath(routes.PresetOriginalPicture),
|
||||||
|
routes.PresetSmallPicturePath,
|
||||||
|
routes.PresetMediumPicturePath,
|
||||||
|
routes.PresetLargePicturePath
|
||||||
|
})
|
||||||
|
{
|
||||||
|
var metadata = await storage.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, CancellationToken.None);
|
||||||
|
Assert.True(metadata is not null, $"expected {key} to have been seeded");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_RunTwiceWithoutOverwrite_StaysIdempotent()
|
||||||
|
{
|
||||||
|
var configuration = NewConfiguration(overwritePresetPictures: false);
|
||||||
|
var (firstSeeder, storage) = Build(configuration);
|
||||||
|
await RunToCompletionAsync(firstSeeder);
|
||||||
|
|
||||||
|
var (secondSeeder, _) = Build(configuration);
|
||||||
|
await RunToCompletionAsync(secondSeeder);
|
||||||
|
|
||||||
|
var metadata = await storage.HeadObjectAsync(
|
||||||
|
InfrastructureTestFixture.S3Bucket, configuration.Routes.PresetSmallPicturePath, CancellationToken.None);
|
||||||
|
Assert.NotNull(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExecuteAsync_SeedPresetPicturesDisabled_UploadsNothing()
|
||||||
|
{
|
||||||
|
var configuration = NewConfiguration(overwritePresetPictures: false);
|
||||||
|
configuration.SeedPresetPictures = false;
|
||||||
|
var (seeder, storage) = Build(configuration);
|
||||||
|
|
||||||
|
await RunToCompletionAsync(seeder);
|
||||||
|
|
||||||
|
var metadata = await storage.HeadObjectAsync(
|
||||||
|
InfrastructureTestFixture.S3Bucket, configuration.Routes.PresetSmallPicturePath, CancellationToken.None);
|
||||||
|
Assert.Null(metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Musify.Infrastructure.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Musify.Infrastructure.Tests.Services;
|
||||||
|
|
||||||
|
public sealed class SingleFlightCacheTests
|
||||||
|
{
|
||||||
|
private static SingleFlightCache CreateCache() => new(new MemoryCache(new MemoryCacheOptions()));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetOrCreateAsync_SecondCallWithinDuration_ReturnsCachedValueWithoutCallingFactory()
|
||||||
|
{
|
||||||
|
var cache = CreateCache();
|
||||||
|
var calls = 0;
|
||||||
|
Task<ErrorOr<int>> Factory() { calls++; return Task.FromResult<ErrorOr<int>>(calls); }
|
||||||
|
|
||||||
|
var first = await cache.GetOrCreateAsync("key", TimeSpan.FromMinutes(1), Factory);
|
||||||
|
var second = await cache.GetOrCreateAsync("key", TimeSpan.FromMinutes(1), Factory);
|
||||||
|
|
||||||
|
Assert.Equal(1, first.Value);
|
||||||
|
Assert.Equal(1, second.Value);
|
||||||
|
Assert.Equal(1, calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetOrCreateAsync_ConcurrentCallsForTheSameKey_CollapseIntoASingleFactoryCall()
|
||||||
|
{
|
||||||
|
var cache = CreateCache();
|
||||||
|
var calls = 0;
|
||||||
|
var gate = new TaskCompletionSource();
|
||||||
|
|
||||||
|
async Task<ErrorOr<int>> Factory()
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref calls);
|
||||||
|
await gate.Task;
|
||||||
|
return 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
var first = cache.GetOrCreateAsync("concurrent-key", TimeSpan.FromMinutes(1), Factory);
|
||||||
|
var second = cache.GetOrCreateAsync("concurrent-key", TimeSpan.FromMinutes(1), Factory);
|
||||||
|
gate.SetResult();
|
||||||
|
|
||||||
|
var results = await Task.WhenAll(first, second);
|
||||||
|
|
||||||
|
Assert.Equal(1, calls);
|
||||||
|
Assert.All(results, result => Assert.Equal(7, result.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetOrCreateAsync_FactoryReturnsError_DoesNotCacheIt()
|
||||||
|
{
|
||||||
|
var cache = CreateCache();
|
||||||
|
var calls = 0;
|
||||||
|
Task<ErrorOr<int>> Factory()
|
||||||
|
{
|
||||||
|
calls++;
|
||||||
|
return Task.FromResult<ErrorOr<int>>(Error.Failure(description: "boom"));
|
||||||
|
}
|
||||||
|
|
||||||
|
await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory);
|
||||||
|
await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory);
|
||||||
|
|
||||||
|
Assert.Equal(2, calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetOrCreateAsync_NonPositiveDuration_AlwaysCallsTheFactory()
|
||||||
|
{
|
||||||
|
var cache = CreateCache();
|
||||||
|
var calls = 0;
|
||||||
|
Task<ErrorOr<int>> Factory() { calls++; return Task.FromResult<ErrorOr<int>>(calls); }
|
||||||
|
|
||||||
|
await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory);
|
||||||
|
await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory);
|
||||||
|
|
||||||
|
Assert.Equal(2, calls);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Musify.Infrastructure.Configuration;
|
||||||
|
using Musify.Infrastructure.Services;
|
||||||
|
using Musify.Infrastructure.Tests.TestSupport;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Musify.Infrastructure.Tests.Services;
|
||||||
|
|
||||||
|
[Collection(InfrastructureCollection.Name)]
|
||||||
|
public sealed class StorageServiceTests(InfrastructureTestFixture fixture)
|
||||||
|
{
|
||||||
|
private StorageService CreateService(out Musify.Infrastructure.Persistence.Database database)
|
||||||
|
{
|
||||||
|
database = fixture.CreateDatabase();
|
||||||
|
var storageConfiguration = new InfrastructureStorageConfiguration
|
||||||
|
{
|
||||||
|
Address = fixture.S3ServiceUrl,
|
||||||
|
AccessKey = InfrastructureTestFixture.S3AccessKey,
|
||||||
|
SecretAccessKey = InfrastructureTestFixture.S3SecretKey,
|
||||||
|
ForcePathStyle = true,
|
||||||
|
UseHttp = true,
|
||||||
|
};
|
||||||
|
return new StorageService(database, fixture.CreateS3Client(), NullLogger<StorageService>.Instance, storageConfiguration);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UploadThenGetFile_RoundTripsTheSameBytes()
|
||||||
|
{
|
||||||
|
var service = CreateService(out var database);
|
||||||
|
await using var _ = database;
|
||||||
|
var key = $"tests/{Guid.NewGuid():N}/round-trip.txt";
|
||||||
|
var content = "hello seaweedfs"u8.ToArray();
|
||||||
|
|
||||||
|
await service.UploadFileAsync(new MemoryStream(content), "text/plain", InfrastructureTestFixture.S3Bucket, key, CancellationToken.None);
|
||||||
|
|
||||||
|
await using var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, key, CancellationToken.None);
|
||||||
|
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||||
|
var readBack = await reader.ReadToEndAsync();
|
||||||
|
|
||||||
|
Assert.Equal("hello seaweedfs", readBack);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HeadObjectAsync_ExistingObject_ReturnsMetadata()
|
||||||
|
{
|
||||||
|
var service = CreateService(out var database);
|
||||||
|
await using var _ = database;
|
||||||
|
var key = $"tests/{Guid.NewGuid():N}/head.txt";
|
||||||
|
await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, CancellationToken.None);
|
||||||
|
|
||||||
|
var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(metadata);
|
||||||
|
Assert.Equal(1, metadata.ContentLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HeadObjectAsync_MissingObject_ReturnsNull()
|
||||||
|
{
|
||||||
|
var service = CreateService(out var database);
|
||||||
|
await using var _ = database;
|
||||||
|
|
||||||
|
var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, $"tests/{Guid.NewGuid():N}/missing.txt", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Null(metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RemoveFileAsync_ExistingObject_DeletesIt()
|
||||||
|
{
|
||||||
|
var service = CreateService(out var database);
|
||||||
|
await using var _ = database;
|
||||||
|
var key = $"tests/{Guid.NewGuid():N}/to-remove.txt";
|
||||||
|
await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, CancellationToken.None);
|
||||||
|
|
||||||
|
await service.RemoveFileAsync(InfrastructureTestFixture.S3Bucket, key, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Null(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CopyFileAsync_CopiesToTheDestinationKey()
|
||||||
|
{
|
||||||
|
var service = CreateService(out var database);
|
||||||
|
await using var _ = database;
|
||||||
|
var sourceKey = $"tests/{Guid.NewGuid():N}/source.txt";
|
||||||
|
var destinationKey = $"tests/{Guid.NewGuid():N}/destination.txt";
|
||||||
|
await service.UploadFileAsync(new MemoryStream("copy-me"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, sourceKey, CancellationToken.None);
|
||||||
|
|
||||||
|
await service.CopyFileAsync(InfrastructureTestFixture.S3Bucket, sourceKey, InfrastructureTestFixture.S3Bucket, destinationKey, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, destinationKey, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ListObjectsAsync_ReturnsEveryObjectUnderThePrefix()
|
||||||
|
{
|
||||||
|
var service = CreateService(out var database);
|
||||||
|
await using var _ = database;
|
||||||
|
var prefix = $"tests/{Guid.NewGuid():N}";
|
||||||
|
await service.UploadFileAsync(new MemoryStream("a"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/a.txt", CancellationToken.None);
|
||||||
|
await service.UploadFileAsync(new MemoryStream("b"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/b.txt", CancellationToken.None);
|
||||||
|
|
||||||
|
var keys = new List<string>();
|
||||||
|
await foreach (var item in service.ListObjectsAsync(InfrastructureTestFixture.S3Bucket, prefix, CancellationToken.None))
|
||||||
|
keys.Add(item.Key);
|
||||||
|
|
||||||
|
Assert.Equal([$"{prefix}/a.txt", $"{prefix}/b.txt"], keys.Order());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RemoveFolderAsync_DeletesEveryObjectUnderThePrefix()
|
||||||
|
{
|
||||||
|
var service = CreateService(out var database);
|
||||||
|
await using var _ = database;
|
||||||
|
var prefix = $"tests/{Guid.NewGuid():N}";
|
||||||
|
await service.UploadFileAsync(new MemoryStream("a"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/a.txt", CancellationToken.None);
|
||||||
|
await service.UploadFileAsync(new MemoryStream("b"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/nested/b.txt", CancellationToken.None);
|
||||||
|
|
||||||
|
await service.RemoveFolderAsync(InfrastructureTestFixture.S3Bucket, prefix, CancellationToken.None);
|
||||||
|
|
||||||
|
var remaining = new List<string>();
|
||||||
|
await foreach (var item in service.ListObjectsAsync(InfrastructureTestFixture.S3Bucket, prefix, CancellationToken.None))
|
||||||
|
remaining.Add(item.Key);
|
||||||
|
Assert.Empty(remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetUploadUrlAsync_PresignedPut_CanActuallyUploadTheObject()
|
||||||
|
{
|
||||||
|
var service = CreateService(out var database);
|
||||||
|
await using var _ = database;
|
||||||
|
var key = $"tests/{Guid.NewGuid():N}/presigned.txt";
|
||||||
|
|
||||||
|
var uploadUrl = await service.GetUploadUrlAsync(
|
||||||
|
InfrastructureTestFixture.S3Bucket, key, "text/plain", TimeSpan.FromMinutes(5), CancellationToken.None);
|
||||||
|
|
||||||
|
using var httpClient = new HttpClient();
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl)
|
||||||
|
{
|
||||||
|
// Not StringContent: it appends "; charset=utf-8" to Content-Type, which no longer
|
||||||
|
// matches the "text/plain" the presigned URL's signature was computed over (SigV4
|
||||||
|
// signs Content-Type byte-for-byte) and the server answers 403 SignatureDoesNotMatch.
|
||||||
|
Content = new ByteArrayContent(Encoding.UTF8.GetBytes("presigned content"))
|
||||||
|
};
|
||||||
|
request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("text/plain");
|
||||||
|
// GetUploadUrlAsync defaults preventOverwrite to true, which signs the URL together with
|
||||||
|
// this header (see StorageService.GetUploadUrlAsync) — the actual PUT has to send it too,
|
||||||
|
// or the signature no longer matches and the server answers 403.
|
||||||
|
request.Headers.TryAddWithoutValidation("If-None-Match", "*");
|
||||||
|
var response = await httpClient.SendAsync(request);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
Assert.True(response.StatusCode == HttpStatusCode.OK, $"status={response.StatusCode}, body={responseBody}");
|
||||||
|
var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, CancellationToken.None);
|
||||||
|
Assert.NotNull(metadata);
|
||||||
|
Assert.Equal("presigned content".Length, metadata.ContentLength);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.IdentityModel.JsonWebTokens;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using Musify.Infrastructure.Configuration;
|
||||||
|
using Musify.Infrastructure.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Musify.Infrastructure.Tests.Services;
|
||||||
|
|
||||||
|
public sealed class StreamTicketServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string privateKeyPath = Path.GetTempFileName();
|
||||||
|
private readonly RSA rsa = RSA.Create(2048);
|
||||||
|
|
||||||
|
public StreamTicketServiceTests() => File.WriteAllText(privateKeyPath, rsa.ExportPkcs8PrivateKeyPem());
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
rsa.Dispose();
|
||||||
|
File.Delete(privateKeyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private StreamTicketService CreateService(int ticketTtlSeconds = 3600) => new(new StreamTicketConfiguration
|
||||||
|
{
|
||||||
|
PrivateKeyPath = privateKeyPath,
|
||||||
|
Audience = "media-gateway",
|
||||||
|
Issuer = "musify-webapi",
|
||||||
|
TicketTtlSeconds = ticketTtlSeconds
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IssueTicket_ReturnsATokenSignedWithTheConfiguredKeyAndClaims()
|
||||||
|
{
|
||||||
|
using var service = CreateService(ticketTtlSeconds: 120);
|
||||||
|
|
||||||
|
var ticket = service.IssueTicket(userId: 42, keyPrefix: "Tracks/ProcessedAudios/abc/");
|
||||||
|
|
||||||
|
Assert.Equal(120, ticket.ExpiresInSeconds);
|
||||||
|
|
||||||
|
var handler = new JsonWebTokenHandler();
|
||||||
|
var jwt = handler.ReadJsonWebToken(ticket.Token);
|
||||||
|
Assert.Equal("42", jwt.GetClaim("sub").Value);
|
||||||
|
Assert.Equal("Tracks/ProcessedAudios/abc/", jwt.GetClaim("prefix").Value);
|
||||||
|
Assert.Equal("musify-webapi", jwt.Issuer);
|
||||||
|
Assert.Contains("media-gateway", jwt.Audiences);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IssueTicket_TokenValidatesAgainstTheMatchingPublicKey()
|
||||||
|
{
|
||||||
|
using var service = CreateService();
|
||||||
|
var ticket = service.IssueTicket(userId: 1, keyPrefix: "Tracks/ProcessedAudios/xyz/");
|
||||||
|
|
||||||
|
var publicKeyPem = rsa.ExportSubjectPublicKeyInfoPem();
|
||||||
|
using var verifyingRsa = RSA.Create();
|
||||||
|
verifyingRsa.ImportFromPem(publicKeyPem);
|
||||||
|
|
||||||
|
var handler = new JsonWebTokenHandler();
|
||||||
|
var result = await handler.ValidateTokenAsync(ticket.Token, new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidIssuer = "musify-webapi",
|
||||||
|
ValidAudience = "media-gateway",
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
IssuerSigningKey = new RsaSecurityKey(verifyingRsa),
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.True(result.IsValid);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Amazon.S3;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Musify.Infrastructure.Configuration;
|
||||||
|
using Musify.Infrastructure.Persistence;
|
||||||
|
using Testcontainers.PostgreSql;
|
||||||
|
using DotNet.Testcontainers.Builders;
|
||||||
|
using DotNet.Testcontainers.Containers;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Musify.Infrastructure.Tests.TestSupport;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One Postgres container and one SeaweedFS container (same image and startup
|
||||||
|
/// command as <c>deploy/compose.yml</c>), started once for every test in the
|
||||||
|
/// <see cref="InfrastructureCollection"/> and torn down when the run finishes.
|
||||||
|
/// EF Core migrations run once here too, against the real <c>Database</c>
|
||||||
|
/// DbContext, so a broken migration fails the whole run immediately.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InfrastructureTestFixture : IAsyncLifetime
|
||||||
|
{
|
||||||
|
public const string S3AccessKey = "musify-test";
|
||||||
|
public const string S3SecretKey = "musify-test-secret";
|
||||||
|
public const string S3Bucket = "musify-test-bucket";
|
||||||
|
|
||||||
|
private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine")
|
||||||
|
.WithDatabase("musify_test")
|
||||||
|
.WithUsername("postgres")
|
||||||
|
.WithPassword("postgres")
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
private readonly IContainer seaweedFs = new ContainerBuilder("chrislusf/seaweedfs:latest")
|
||||||
|
.WithEntrypoint("/bin/sh", "-c")
|
||||||
|
.WithCommand(BuildSeaweedFsStartupScript())
|
||||||
|
.WithPortBinding(9333, assignRandomHostPort: true)
|
||||||
|
.WithPortBinding(8333, assignRandomHostPort: true)
|
||||||
|
.WithWaitStrategy(Wait.ForUnixContainer()
|
||||||
|
.UntilHttpRequestIsSucceeded(request => request.ForPort(9333).ForPath("/cluster/healthz")))
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
public string ConnectionString => postgres.GetConnectionString();
|
||||||
|
|
||||||
|
public string S3ServiceUrl => $"http://{seaweedFs.Hostname}:{seaweedFs.GetMappedPublicPort(8333)}";
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
await Task.WhenAll(postgres.StartAsync(), seaweedFs.StartAsync());
|
||||||
|
|
||||||
|
await using var database = new Database(new DatabaseConfiguration { ConnectionString = ConnectionString });
|
||||||
|
await database.Database.MigrateAsync();
|
||||||
|
|
||||||
|
// SeaweedFS does not auto-create buckets (see deploy/compose.yml's seaweedfs-init).
|
||||||
|
using var s3 = CreateS3Client();
|
||||||
|
await s3.PutBucketAsync(S3Bucket);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DisposeAsync()
|
||||||
|
{
|
||||||
|
await postgres.DisposeAsync();
|
||||||
|
await seaweedFs.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A fresh, DI-wired <see cref="Database"/>, so <c>AuditableEntityInterceptor</c>
|
||||||
|
/// (attached in <c>AddDatabase</c>'s DI registration, not in the class itself) is active —
|
||||||
|
/// same wiring the real app uses, not a bare <c>new Database(...)</c>.</summary>
|
||||||
|
public Database CreateDatabase()
|
||||||
|
{
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection([new("Database:ConnectionString", ConnectionString)])
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddDatabase(configuration);
|
||||||
|
return services.BuildServiceProvider().GetRequiredService<Database>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An <see cref="IAmazonS3"/> client configured exactly like
|
||||||
|
/// <c>ServicesDependencyInjection.AddStorageService</c>, pointed at the SeaweedFS container.</summary>
|
||||||
|
public IAmazonS3 CreateS3Client() =>
|
||||||
|
new AmazonS3Client(S3AccessKey, S3SecretKey, new AmazonS3Config
|
||||||
|
{
|
||||||
|
ServiceURL = S3ServiceUrl,
|
||||||
|
ForcePathStyle = true,
|
||||||
|
UseHttp = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
private static string BuildSeaweedFsStartupScript()
|
||||||
|
{
|
||||||
|
var identity = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
identities = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
name = "musify-test",
|
||||||
|
credentials = new[] { new { accessKey = S3AccessKey, secretKey = S3SecretKey } },
|
||||||
|
actions = new[] { "Admin", "Read", "Write" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $"""
|
||||||
|
cat > /tmp/s3.conf <<'JSON'
|
||||||
|
{identity}
|
||||||
|
JSON
|
||||||
|
exec weed server -ip.bind=0.0.0.0 -dir=/data -master.volumeSizeLimitMB=1024 -volume.max=0 -filer -s3 -s3.config=/tmp/s3.conf
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[CollectionDefinition(Name)]
|
||||||
|
public sealed class InfrastructureCollection : ICollectionFixture<InfrastructureTestFixture>
|
||||||
|
{
|
||||||
|
public const string Name = "Infrastructure";
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.IdentityModel.JsonWebTokens;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using Musify.StreamingGateway.Authentication;
|
||||||
|
using Musify.StreamingGateway.Configuration;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Musify.StreamingGateway.Tests.Authentication;
|
||||||
|
|
||||||
|
public sealed class TicketValidatorTests : IDisposable
|
||||||
|
{
|
||||||
|
private const string Issuer = "musify-webapi";
|
||||||
|
private const string Audience = "media-gateway";
|
||||||
|
|
||||||
|
private readonly string publicKeyPath = Path.GetTempFileName();
|
||||||
|
private readonly RSA rsa = RSA.Create(2048);
|
||||||
|
private readonly JsonWebTokenHandler tokenHandler = new();
|
||||||
|
|
||||||
|
public TicketValidatorTests() => File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem());
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
rsa.Dispose();
|
||||||
|
File.Delete(publicKeyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TicketValidator CreateValidator() => new(Options.Create(new StreamTicketValidationOptions
|
||||||
|
{
|
||||||
|
PublicKeyPath = publicKeyPath,
|
||||||
|
Audience = Audience,
|
||||||
|
Issuer = Issuer
|
||||||
|
}));
|
||||||
|
|
||||||
|
private string IssueToken(string prefix = "Tracks/ProcessedAudios/abc/", string issuer = Issuer, string audience = Audience,
|
||||||
|
DateTime? expires = null, DateTime? notBefore = null)
|
||||||
|
{
|
||||||
|
var descriptor = new SecurityTokenDescriptor
|
||||||
|
{
|
||||||
|
Issuer = issuer,
|
||||||
|
Audience = audience,
|
||||||
|
NotBefore = notBefore ?? DateTime.UtcNow.AddMinutes(-1),
|
||||||
|
Expires = expires ?? DateTime.UtcNow.AddMinutes(5),
|
||||||
|
Claims = new Dictionary<string, object> { ["prefix"] = prefix },
|
||||||
|
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
|
||||||
|
};
|
||||||
|
return tokenHandler.CreateToken(descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateTicketAsync_ValidToken_ReturnsThePrefixClaim()
|
||||||
|
{
|
||||||
|
var token = IssueToken(prefix: "Tracks/ProcessedAudios/xyz/");
|
||||||
|
|
||||||
|
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
||||||
|
|
||||||
|
Assert.Equal("Tracks/ProcessedAudios/xyz/", prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public async Task ValidateTicketAsync_BlankToken_ReturnsEmptyString(string? token)
|
||||||
|
{
|
||||||
|
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateTicketAsync_MalformedToken_ReturnsEmptyString()
|
||||||
|
{
|
||||||
|
var prefix = await CreateValidator().ValidateTicketAsync("not-a-real-jwt");
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateTicketAsync_ExpiredToken_ReturnsEmptyString()
|
||||||
|
{
|
||||||
|
var token = IssueToken(expires: DateTime.UtcNow.AddMinutes(-40), notBefore: DateTime.UtcNow.AddMinutes(-50));
|
||||||
|
|
||||||
|
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateTicketAsync_WrongIssuer_ReturnsEmptyString()
|
||||||
|
{
|
||||||
|
var token = IssueToken(issuer: "someone-else");
|
||||||
|
|
||||||
|
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateTicketAsync_WrongAudience_ReturnsEmptyString()
|
||||||
|
{
|
||||||
|
var token = IssueToken(audience: "someone-else");
|
||||||
|
|
||||||
|
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateTicketAsync_SignedWithADifferentKey_ReturnsEmptyString()
|
||||||
|
{
|
||||||
|
using var otherKey = RSA.Create(2048);
|
||||||
|
var descriptor = new SecurityTokenDescriptor
|
||||||
|
{
|
||||||
|
Issuer = Issuer,
|
||||||
|
Audience = Audience,
|
||||||
|
NotBefore = DateTime.UtcNow.AddMinutes(-1),
|
||||||
|
Expires = DateTime.UtcNow.AddMinutes(5),
|
||||||
|
Claims = new Dictionary<string, object> { ["prefix"] = "Tracks/ProcessedAudios/abc/" },
|
||||||
|
SigningCredentials = new SigningCredentials(new RsaSecurityKey(otherKey), SecurityAlgorithms.RsaSha256),
|
||||||
|
};
|
||||||
|
var token = tokenHandler.CreateToken(descriptor);
|
||||||
|
|
||||||
|
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateTicketAsync_MissingPrefixClaim_ReturnsEmptyString()
|
||||||
|
{
|
||||||
|
var descriptor = new SecurityTokenDescriptor
|
||||||
|
{
|
||||||
|
Issuer = Issuer,
|
||||||
|
Audience = Audience,
|
||||||
|
NotBefore = DateTime.UtcNow.AddMinutes(-1),
|
||||||
|
Expires = DateTime.UtcNow.AddMinutes(5),
|
||||||
|
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
|
||||||
|
};
|
||||||
|
var token = tokenHandler.CreateToken(descriptor);
|
||||||
|
|
||||||
|
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
||||||
|
|
||||||
|
Assert.Equal(string.Empty, prefix);
|
||||||
|
}
|
||||||
|
}
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.IdentityModel.JsonWebTokens;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using Musify.StreamingGateway.Authentication;
|
||||||
|
using Musify.StreamingGateway.Configuration;
|
||||||
|
using Musify.StreamingGateway.Middleware;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Musify.StreamingGateway.Tests.Middleware;
|
||||||
|
|
||||||
|
public sealed class TicketValidationMiddlewareTests : IDisposable
|
||||||
|
{
|
||||||
|
private const string Prefix = "Tracks/ProcessedAudios/abc";
|
||||||
|
|
||||||
|
private readonly string publicKeyPath = Path.GetTempFileName();
|
||||||
|
private readonly RSA rsa = RSA.Create(2048);
|
||||||
|
private readonly StreamTicketValidationOptions options;
|
||||||
|
|
||||||
|
public TicketValidationMiddlewareTests()
|
||||||
|
{
|
||||||
|
File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem());
|
||||||
|
options = new StreamTicketValidationOptions
|
||||||
|
{
|
||||||
|
PublicKeyPath = publicKeyPath,
|
||||||
|
Audience = "media-gateway",
|
||||||
|
Issuer = "musify-webapi",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
rsa.Dispose();
|
||||||
|
File.Delete(publicKeyPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string IssueValidToken(string prefix = Prefix) => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
|
||||||
|
{
|
||||||
|
Issuer = options.Issuer,
|
||||||
|
Audience = options.Audience,
|
||||||
|
NotBefore = DateTime.UtcNow.AddMinutes(-1),
|
||||||
|
Expires = DateTime.UtcNow.AddMinutes(5),
|
||||||
|
Claims = new Dictionary<string, object> { ["prefix"] = prefix },
|
||||||
|
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
|
||||||
|
});
|
||||||
|
|
||||||
|
private (TicketValidationMiddleware Middleware, Func<bool> WasNextCalled) CreateMiddleware()
|
||||||
|
{
|
||||||
|
var nextCalled = false;
|
||||||
|
RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; };
|
||||||
|
var validator = new TicketValidator(Options.Create(options));
|
||||||
|
var middleware = new TicketValidationMiddleware(next, validator, Options.Create(options), NullLogger<TicketValidationMiddleware>.Instance);
|
||||||
|
return (middleware, () => nextCalled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_PathOutsideMediaPrefix_PassesThroughWithoutValidatingATicket()
|
||||||
|
{
|
||||||
|
var (middleware, wasNextCalled) = CreateMiddleware();
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
context.Request.Path = "/health";
|
||||||
|
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
Assert.True(wasNextCalled());
|
||||||
|
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_NoTicket_ReturnsUnauthorized()
|
||||||
|
{
|
||||||
|
var (middleware, wasNextCalled) = CreateMiddleware();
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
context.Request.Path = $"/media/{Prefix}/audio.m4a";
|
||||||
|
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
Assert.False(wasNextCalled());
|
||||||
|
Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_ValidTicketAsQueryParameter_ForTheRequestedObject_PassesThroughAndStripsTheTicket()
|
||||||
|
{
|
||||||
|
var (middleware, wasNextCalled) = CreateMiddleware();
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
context.Request.Path = $"/media/{Prefix}/audio.m4a";
|
||||||
|
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}&keep=1");
|
||||||
|
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
Assert.True(wasNextCalled());
|
||||||
|
Assert.Equal("keep=1", context.Request.QueryString.Value?.TrimStart('?'));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_ValidTicketAsHeader_PassesThrough()
|
||||||
|
{
|
||||||
|
var (middleware, wasNextCalled) = CreateMiddleware();
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
context.Request.Path = $"/media/{Prefix}/audio.m4a";
|
||||||
|
context.Request.Headers["X-Stream-Ticket"] = IssueValidToken();
|
||||||
|
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
Assert.True(wasNextCalled());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_ObjectKeyOutsideAuthorizedPrefix_ReturnsForbidden()
|
||||||
|
{
|
||||||
|
var (middleware, wasNextCalled) = CreateMiddleware();
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
context.Request.Path = "/media/Tracks/ProcessedAudios/someone-elses-track/audio.m4a";
|
||||||
|
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}");
|
||||||
|
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
Assert.False(wasNextCalled());
|
||||||
|
Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData($"/media/{Prefix}/../../etc/passwd")]
|
||||||
|
[InlineData($"/media/{Prefix}/./audio.m4a")]
|
||||||
|
public async Task InvokeAsync_PathTraversalSegment_ReturnsBadRequest(string path)
|
||||||
|
{
|
||||||
|
var (middleware, wasNextCalled) = CreateMiddleware();
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
context.Request.Path = path;
|
||||||
|
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}");
|
||||||
|
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
Assert.False(wasNextCalled());
|
||||||
|
Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_TicketPrefixExactlyMatchesTheRequestedObject_PassesThrough()
|
||||||
|
{
|
||||||
|
var (middleware, wasNextCalled) = CreateMiddleware();
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
context.Request.Path = $"/media/{Prefix}";
|
||||||
|
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}");
|
||||||
|
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
Assert.True(wasNextCalled());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<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="..\..\Hosts\Musify.StreamingGateway\Musify.StreamingGateway.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
+39
-6
@@ -78,13 +78,21 @@ openssl rsa -in deploy/keys/stream_private.pem -pubout -out deploy/keys/stream_p
|
|||||||
dotnet test backend/Musify.slnx
|
dotnet test backend/Musify.slnx
|
||||||
```
|
```
|
||||||
|
|
||||||
`backend/Tests/` (carpeta `/Tests/` en el `.slnx`) tiene dos proyectos xUnit,
|
`backend/Tests/` (carpeta `/Tests/` en el `.slnx`) tiene cinco proyectos xUnit:
|
||||||
sin Docker ni Postgres real:
|
|
||||||
|
|
||||||
| Proyecto | Cubre |
|
| Proyecto | Cubre | Docker |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| `Musify.Domain.Tests` | Lógica de los value objects (`TrackAudio`/`TrackPictures`: `IsProcessed`, `IsFailed`, `IsInProgress`). Las entidades son en su mayoría anémicas — sin comportamiento propio, nada más que testear ahí. |
|
| `Musify.Domain.Tests` | Lógica de los value objects (`TrackAudio`/`TrackPictures`: `IsProcessed`, `IsFailed`, `IsInProgress`). Las entidades son en su mayoría anémicas — sin comportamiento propio, nada más que testear ahí. | No |
|
||||||
| `Musify.Application.Tests` | Los ~45 handlers y servicios de `Musify.Application` (Albums, Tracks, PlayLists, Users, Mixes, YouTube). |
|
| `Musify.Application.Tests` | Los ~45 handlers y servicios de `Musify.Application` (Albums, Tracks, PlayLists, Users, Mixes, YouTube). | No |
|
||||||
|
| `Musify.Infrastructure.Tests` | `Database`/migraciones, `StorageService`, `StreamTicketService`, `AuditableEntityInterceptor`, `PictureService`, `SingleFlightCache`, `PlayListPresetSeeder`. | **Sí** |
|
||||||
|
| `Musify.Api.Tests` | Endpoints HTTP reales (routing, auth, `ValidationFilter`, mapeo `ErrorOr`→HTTP) vía `WebApplicationFactory`, más `ErrorOrHttpExtensions`/`CurrentUser`/validators FluentValidation en aislado. | **Sí** |
|
||||||
|
| `Musify.StreamingGateway.Tests` | `TicketValidator` (RS256) y `TicketValidationMiddleware` (traversal, límites de prefijo, extracción de ticket). | No |
|
||||||
|
|
||||||
|
`Musify.Worker` no tiene proyecto de test: su `Program.cs` es puro *wiring* de
|
||||||
|
DI/host (Hangfire, MassTransit, jobs) — la lógica real que ejecuta vive en
|
||||||
|
`Musify.Infrastructure`, ya cubierta ahí.
|
||||||
|
|
||||||
|
### Application.Tests: por qué SQLite en memoria y no mocks
|
||||||
|
|
||||||
`IDatabase` expone `DbSet<T>` directamente, así que los handlers arman LINQ
|
`IDatabase` expone `DbSet<T>` directamente, así que los handlers arman LINQ
|
||||||
real (`Where`, `Include`, `Select`...) contra él — no se puede mockear eso con
|
real (`Where`, `Include`, `Select`...) contra él — no se puede mockear eso con
|
||||||
@@ -97,6 +105,31 @@ construyen entidades y configuración con valores por defecto sensatos.
|
|||||||
`IStorageService`, `IEventBus`, `IYouTubeMusicService`, etc. sí se mockean con
|
`IStorageService`, `IEventBus`, `IYouTubeMusicService`, etc. sí se mockean con
|
||||||
NSubstitute, al ser interfaces normales.
|
NSubstitute, al ser interfaces normales.
|
||||||
|
|
||||||
|
### Infrastructure.Tests y Api.Tests: Testcontainers
|
||||||
|
|
||||||
|
Estos dos sí necesitan Docker corriendo. Levantan un Postgres real
|
||||||
|
(`Testcontainers.PostgreSql`) y, en `Infrastructure.Tests`, también un
|
||||||
|
SeaweedFS real (mismo `chrislusf/seaweedfs:latest` e imagen/comando que
|
||||||
|
`deploy/compose.yml`, no MinIO) — así `StorageService` se prueba contra el
|
||||||
|
mismo backend S3-compatible que usa producción, no contra AWS S3. Los
|
||||||
|
contenedores se levantan una vez por corrida (`ICollectionFixture`), con
|
||||||
|
migraciones EF Core reales aplicadas contra el Postgres efímero.
|
||||||
|
|
||||||
|
`Api.Tests` usa `WebApplicationFactory<Program>` contra ese mismo Postgres —
|
||||||
|
routing, auth y validación reales — pero sustituye por fakes los servicios que
|
||||||
|
hablan por red (`IStorageService`, `IEventBus`, `IYouTubeMusicService`,
|
||||||
|
`IStreamTicketService`) y el esquema de autenticación JWT real por un handler
|
||||||
|
de prueba (ver `TestSupport/FakeAuthenticationHandler.cs`) que autentica según
|
||||||
|
un header, sin necesitar un Zitadel real.
|
||||||
|
|
||||||
|
Escribir estos tests contra el backend real encontró y arregló dos bugs reales
|
||||||
|
(no simulados): `StorageService.RemoveFolderAsync` reventaba con
|
||||||
|
`NullReferenceException` contra SeaweedFS (su respuesta `DeleteObjects` omite
|
||||||
|
la lista `<Error>` cuando no hay errores, a diferencia de AWS S3), y
|
||||||
|
`AuditableEntityInterceptor` nunca actualizaba `UpdatedAt` en una actualización
|
||||||
|
normal (carga → modifica → guarda) porque corría antes de que EF detectara los
|
||||||
|
cambios pendientes.
|
||||||
|
|
||||||
## Config (AppSettings)
|
## Config (AppSettings)
|
||||||
|
|
||||||
Convención: `AppSettings.json` es la plantilla con valores vacíos o neutros;
|
Convención: `AppSettings.json` es la plantilla con valores vacíos o neutros;
|
||||||
|
|||||||
+6
-4
@@ -59,11 +59,13 @@ Cliente web (SvelteKit 2 + Svelte 5 + Tailwind 4). La sesión OIDC vive en el
|
|||||||
servidor (cookie cifrada); las llamadas al backend salen del servidor con el
|
servidor (cookie cifrada); las llamadas al backend salen del servidor con el
|
||||||
access token, y las subidas se hacen contra las URLs prefirmadas de S3.
|
access token, y las subidas se hacen contra las URLs prefirmadas de S3.
|
||||||
|
|
||||||
## Musify.Domain.Tests + Musify.Application.Tests
|
## Tests
|
||||||
|
|
||||||
`backend/Tests/`, carpeta `/Tests/` en el `.slnx`. xUnit, sin Docker ni Postgres
|
`backend/Tests/`, carpeta `/Tests/` en el `.slnx`: cinco proyectos xUnit —
|
||||||
real — `Application.Tests` corre los handlers contra SQLite en memoria (ver
|
`Musify.Domain.Tests`, `Musify.Application.Tests` (SQLite en memoria, sin
|
||||||
[development.md](development.md#tests)).
|
Docker), `Musify.Infrastructure.Tests` y `Musify.Api.Tests` (Testcontainers:
|
||||||
|
Postgres + SeaweedFS reales) y `Musify.StreamingGateway.Tests`. Detalle en
|
||||||
|
[development.md](development.md#tests).
|
||||||
|
|
||||||
## Otras carpetas
|
## Otras carpetas
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user