Design Prot Merge #1

Merged
NotS1mp merged 217 commits from design-prot into master 2026-09-21 15:48:16 +00:00
73 changed files with 3718 additions and 3645 deletions
Showing only changes of commit b5700614c1 - Show all commits
@@ -3,82 +3,83 @@ using Microsoft.AspNetCore.Http;
using Musify.Api.Authentication; using Musify.Api.Authentication;
using Xunit; using Xunit;
namespace Musify.Api.Tests.Authentication; namespace Musify.Api.Tests.Authentication
public sealed class CurrentUserTests
{ {
private static async Task<CurrentUser> BindAsync(ClaimsPrincipal principal) public sealed class CurrentUserTests
{ {
var context = new DefaultHttpContext { User = principal }; private static async Task<CurrentUser> BindAsync(ClaimsPrincipal principal)
var bound = await CurrentUser.BindAsync(context, parameter: null!); {
return bound!; var context = new DefaultHttpContext { User = principal };
} var bound = await CurrentUser.BindAsync(context, parameter: null!);
return bound!;
}
private static ClaimsPrincipal AuthenticatedPrincipal(params Claim[] claims) => private static ClaimsPrincipal AuthenticatedPrincipal(params Claim[] claims) =>
new(new ClaimsIdentity(claims, authenticationType: "TestAuth")); new(new ClaimsIdentity(claims, authenticationType: "TestAuth"));
[Fact] [Fact]
public async Task BindAsync_AuthenticatedPrincipalWithValidClaims_PopulatesEveryField() public async Task BindAsync_AuthenticatedPrincipalWithValidClaims_PopulatesEveryField()
{ {
var principal = AuthenticatedPrincipal( var principal = AuthenticatedPrincipal(
new Claim(ClaimTypes.NameIdentifier, "42"), new Claim(ClaimTypes.NameIdentifier, "42"),
new Claim(ClaimTypes.Name, "jdoe"), new Claim(ClaimTypes.Name, "jdoe"),
new Claim(ClaimTypes.GivenName, "Jane"), new Claim(ClaimTypes.GivenName, "Jane"),
new Claim(ClaimTypes.Surname, "Doe")); new Claim(ClaimTypes.Surname, "Doe"));
var user = await BindAsync(principal); var user = await BindAsync(principal);
Assert.True(user.IsAuthenticated); Assert.True(user.IsAuthenticated);
Assert.Equal(42, user.Id); Assert.Equal(42, user.Id);
Assert.Equal(42, user.RequiredId); Assert.Equal(42, user.RequiredId);
Assert.Equal("jdoe", user.Username); Assert.Equal("jdoe", user.Username);
Assert.Equal("Jane", user.FirstName); Assert.Equal("Jane", user.FirstName);
Assert.Equal("Doe", user.LastName); Assert.Equal("Doe", user.LastName);
} }
[Fact] [Fact]
public async Task BindAsync_AnonymousPrincipal_IsNotAuthenticatedAndHasNoId() public async Task BindAsync_AnonymousPrincipal_IsNotAuthenticatedAndHasNoId()
{ {
var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity())); var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity()));
Assert.False(user.IsAuthenticated); Assert.False(user.IsAuthenticated);
Assert.Null(user.Id); Assert.Null(user.Id);
} }
[Theory] [Theory]
[InlineData("not-a-number")] [InlineData("not-a-number")]
[InlineData("0")] [InlineData("0")]
[InlineData("")] [InlineData("")]
public async Task BindAsync_NonNumericOrZeroNameIdentifier_LeavesIdNull(string rawId) public async Task BindAsync_NonNumericOrZeroNameIdentifier_LeavesIdNull(string rawId)
{ {
var user = await BindAsync(AuthenticatedPrincipal(new Claim(ClaimTypes.NameIdentifier, rawId))); var user = await BindAsync(AuthenticatedPrincipal(new Claim(ClaimTypes.NameIdentifier, rawId)));
Assert.Null(user.Id); Assert.Null(user.Id);
} }
[Fact] [Fact]
public async Task RequiredId_MissingId_Throws() public async Task RequiredId_MissingId_Throws()
{ {
var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity())); var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity()));
Assert.Throws<InvalidOperationException>(() => user.RequiredId); Assert.Throws<InvalidOperationException>(() => user.RequiredId);
} }
[Fact] [Fact]
public async Task HasClaim_ExistingClaimType_ReturnsTrue() public async Task HasClaim_ExistingClaimType_ReturnsTrue()
{ {
var user = await BindAsync(AuthenticatedPrincipal(new Claim("role", "admin"))); var user = await BindAsync(AuthenticatedPrincipal(new Claim("role", "admin")));
Assert.True(user.HasClaim("role")); Assert.True(user.HasClaim("role"));
Assert.False(user.HasClaim("missing")); Assert.False(user.HasClaim("missing"));
} }
[Fact] [Fact]
public async Task FindClaim_ExistingClaimType_ReturnsItsValue() public async Task FindClaim_ExistingClaimType_ReturnsItsValue()
{ {
var user = await BindAsync(AuthenticatedPrincipal(new Claim("picture", "https://avatar.example/jane.png"))); var user = await BindAsync(AuthenticatedPrincipal(new Claim("picture", "https://avatar.example/jane.png")));
Assert.Equal("https://avatar.example/jane.png", user.FindClaim("picture")); Assert.Equal("https://avatar.example/jane.png", user.FindClaim("picture"));
Assert.Null(user.FindClaim("missing")); Assert.Null(user.FindClaim("missing"));
}
} }
} }
@@ -6,93 +6,94 @@ using Musify.Api.Tests.TestSupport;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Xunit; using Xunit;
namespace Musify.Api.Tests.Endpoints; namespace Musify.Api.Tests.Endpoints
[Collection(ApiCollection.Name)]
public sealed class AlbumEndpointsTests(ApiTestFixture fixture)
{ {
private async Task<long> CreateUserAsync() [Collection(ApiCollection.Name)]
public sealed class AlbumEndpointsTests(ApiTestFixture fixture)
{ {
var userId = Random.Shared.NextInt64(1, long.MaxValue); private async Task<long> CreateUserAsync()
var response = await fixture.CreateAnonymousClient() {
.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null)); var userId = Random.Shared.NextInt64(1, long.MaxValue);
response.EnsureSuccessStatusCode(); var response = await fixture.CreateAnonymousClient()
return userId; .PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null));
} response.EnsureSuccessStatusCode();
return userId;
}
[Fact] [Fact]
public async Task PostAlbums_NoAuthHeader_ReturnsUnauthorized() public async Task PostAlbums_NoAuthHeader_ReturnsUnauthorized()
{ {
var response = await fixture.CreateAnonymousClient() var response = await fixture.CreateAnonymousClient()
.PostAsJsonAsync("/albums", new CreateAlbumRequest("Unauthorized Album", null, null), TestContext.Current.CancellationToken); .PostAsJsonAsync("/albums", new CreateAlbumRequest("Unauthorized Album", null, null), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
} }
[Fact] [Fact]
public async Task PostAlbums_AuthenticatedExistingUser_ReturnsCreatedAlbum() public async Task PostAlbums_AuthenticatedExistingUser_ReturnsCreatedAlbum()
{ {
var userId = await CreateUserAsync(); var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId); var client = fixture.CreateAuthenticatedClient(userId);
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024), TestContext.Current.CancellationToken); var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode); Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken); var body = await response.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(body); Assert.NotNull(body);
Assert.Equal("My Album", body.Title); Assert.Equal("My Album", body.Title);
Assert.Equal(userId, body.OwnerUserId); Assert.Equal(userId, body.OwnerUserId);
} }
[Fact] [Fact]
public async Task PostAlbums_BlankTitle_ReturnsValidationProblem() public async Task PostAlbums_BlankTitle_ReturnsValidationProblem()
{ {
var userId = await CreateUserAsync(); var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId); var client = fixture.CreateAuthenticatedClient(userId);
var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("", null, null), TestContext.Current.CancellationToken); var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("", null, null), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
} }
[Fact] [Fact]
public async Task GetAlbumById_AfterCreate_ReturnsIt() public async Task GetAlbumById_AfterCreate_ReturnsIt()
{ {
var userId = await CreateUserAsync(); var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId); var client = fixture.CreateAuthenticatedClient(userId);
var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Findable", null, null), TestContext.Current.CancellationToken)) var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Findable", null, null), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken); .Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(HttpStatusCode.OK, response.StatusCode);
} }
[Fact] [Fact]
public async Task DeleteAlbum_NotTheOwner_ReturnsUnauthorized() public async Task DeleteAlbum_NotTheOwner_ReturnsUnauthorized()
{ {
var owner = await CreateUserAsync(); var owner = await CreateUserAsync();
var stranger = await CreateUserAsync(); var stranger = await CreateUserAsync();
var created = await (await fixture.CreateAuthenticatedClient(owner).PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null), TestContext.Current.CancellationToken)) var created = await (await fixture.CreateAuthenticatedClient(owner).PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken); .Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
} }
[Fact] [Fact]
public async Task DeleteAlbum_Owner_ReturnsNoContentAndAlbumIsGone() public async Task DeleteAlbum_Owner_ReturnsNoContentAndAlbumIsGone()
{ {
var userId = await CreateUserAsync(); var userId = await CreateUserAsync();
var client = fixture.CreateAuthenticatedClient(userId); var client = fixture.CreateAuthenticatedClient(userId);
var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Deletable", null, null), TestContext.Current.CancellationToken)) var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Deletable", null, null), TestContext.Current.CancellationToken))
.Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken); .Content.ReadFromJsonAsync<AlbumApplicationResponse>(TestContext.Current.CancellationToken);
var deleteResponse = await client.DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); var deleteResponse = await client.DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
var getResponse = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created.Id}", TestContext.Current.CancellationToken); var getResponse = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created.Id}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode); Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode);
}
} }
} }
@@ -5,69 +5,70 @@ using Musify.Api.Tests.TestSupport;
using Musify.Application.Users.Responses; using Musify.Application.Users.Responses;
using Xunit; using Xunit;
namespace Musify.Api.Tests.Endpoints; namespace Musify.Api.Tests.Endpoints
[Collection(ApiCollection.Name)]
public sealed class UserEndpointsTests(ApiTestFixture fixture)
{ {
[Fact] [Collection(ApiCollection.Name)]
public async Task PostUsers_NewUser_ReturnsCreatedWithLocationHeader() public sealed class UserEndpointsTests(ApiTestFixture fixture)
{ {
var client = fixture.CreateAnonymousClient(); [Fact]
var userId = Random.Shared.NextInt64(1, long.MaxValue); 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"), TestContext.Current.CancellationToken); var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Jane Doe", "Jane", "Doe"), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Created, response.StatusCode); Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.Equal($"/users/{userId}", response.Headers.Location?.OriginalString); Assert.Equal($"/users/{userId}", response.Headers.Location?.OriginalString);
var body = await response.Content.ReadFromJsonAsync<UserApplicationResponse>(TestContext.Current.CancellationToken); var body = await response.Content.ReadFromJsonAsync<UserApplicationResponse>(TestContext.Current.CancellationToken);
Assert.NotNull(body); Assert.NotNull(body);
Assert.Equal("Jane Doe", body.Name); Assert.Equal("Jane Doe", body.Name);
} }
[Fact] [Fact]
public async Task PostUsers_BlankName_ReturnsValidationProblem() public async Task PostUsers_BlankName_ReturnsValidationProblem()
{ {
var client = fixture.CreateAnonymousClient(); var client = fixture.CreateAnonymousClient();
var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(Random.Shared.NextInt64(1, long.MaxValue), "", null, null), TestContext.Current.CancellationToken); var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(Random.Shared.NextInt64(1, long.MaxValue), "", null, null), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
} }
[Fact] [Fact]
public async Task PostUsers_DuplicateId_ReturnsConflict() public async Task PostUsers_DuplicateId_ReturnsConflict()
{ {
var client = fixture.CreateAnonymousClient(); var client = fixture.CreateAnonymousClient();
var userId = Random.Shared.NextInt64(1, long.MaxValue); var userId = Random.Shared.NextInt64(1, long.MaxValue);
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "First", null, null), TestContext.Current.CancellationToken); await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "First", null, null), TestContext.Current.CancellationToken);
var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Second", null, null), TestContext.Current.CancellationToken); var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Second", null, null), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
} }
[Fact] [Fact]
public async Task GetUserById_ExistingUser_ReturnsIt() public async Task GetUserById_ExistingUser_ReturnsIt()
{ {
var client = fixture.CreateAnonymousClient(); var client = fixture.CreateAnonymousClient();
var userId = Random.Shared.NextInt64(1, long.MaxValue); var userId = Random.Shared.NextInt64(1, long.MaxValue);
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Findable User", null, null), TestContext.Current.CancellationToken); await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Findable User", null, null), TestContext.Current.CancellationToken);
var response = await client.GetAsync($"/users/{userId}", TestContext.Current.CancellationToken); var response = await client.GetAsync($"/users/{userId}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<UserApplicationResponse>(TestContext.Current.CancellationToken); var body = await response.Content.ReadFromJsonAsync<UserApplicationResponse>(TestContext.Current.CancellationToken);
Assert.Equal("Findable User", body?.Name); Assert.Equal("Findable User", body?.Name);
} }
[Fact] [Fact]
public async Task GetUserById_MissingUser_ReturnsNotFound() public async Task GetUserById_MissingUser_ReturnsNotFound()
{ {
var client = fixture.CreateAnonymousClient(); var client = fixture.CreateAnonymousClient();
var response = await client.GetAsync($"/users/{Random.Shared.NextInt64(1, long.MaxValue)}", TestContext.Current.CancellationToken); var response = await client.GetAsync($"/users/{Random.Shared.NextInt64(1, long.MaxValue)}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
} }
} }
@@ -4,134 +4,135 @@ using Microsoft.Extensions.DependencyInjection;
using Musify.Api.Extensions; using Musify.Api.Extensions;
using Xunit; using Xunit;
namespace Musify.Api.Tests.Extensions; namespace Musify.Api.Tests.Extensions
public sealed class ErrorOrHttpExtensionsTests
{ {
// The concrete IResult types (NoContent, UnauthorizedHttpResult, ...) resolve logging/problem- public sealed class ErrorOrHttpExtensionsTests
// 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 }; // The concrete IResult types (NoContent, UnauthorizedHttpResult, ...) resolve logging/problem-
var bodyStream = new MemoryStream(); // details services from HttpContext.RequestServices when they execute, so it can't be empty.
context.Response.Body = bodyStream; private static readonly IServiceProvider Services = new ServiceCollection().AddLogging().AddProblemDetails().BuildServiceProvider();
await result.ExecuteAsync(context); private static async Task<(int StatusCode, string Body)> ExecuteAsync(IResult result)
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"), var context = new DefaultHttpContext { RequestServices = Services };
Error.Validation("Name", "Name is too long"), var bodyStream = new MemoryStream();
Error.Validation("Age", "Age must be positive"), context.Response.Body = bodyStream;
};
var (statusCode, body) = await ExecuteAsync(result.ToHttpResult()); await result.ExecuteAsync(context);
Assert.Equal(StatusCodes.Status400BadRequest, statusCode); bodyStream.Position = 0;
Assert.Contains("Name is required", body); using var reader = new StreamReader(bodyStream);
Assert.Contains("Age must be positive", body); return (context.Response.StatusCode, await reader.ReadToEndAsync());
} }
[Fact] [Fact]
public async Task ToCreatedResult_Success_ReturnsCreatedAtTheGivenLocation() public async Task ToHttpResult_Success_ReturnsNoContent()
{ {
ErrorOr<string> result = "the-id"; ErrorOr<Success> result = new Success();
var context = new DefaultHttpContext { RequestServices = Services, Response = { Body = new MemoryStream() } }; var (statusCode, _) = await ExecuteAsync(result.ToHttpResult());
await result.ToCreatedResult(value => $"/things/{value}").ExecuteAsync(context);
Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode); Assert.Equal(StatusCodes.Status204NoContent, statusCode);
Assert.Equal("/things/the-id", context.Response.Headers.Location); }
}
[Fact] [Fact]
public async Task ToCreatedResult_Error_ReturnsTheMappedErrorStatus() public async Task ToHttpResult_Value_ReturnsOkWithTheValue()
{ {
ErrorOr<string> result = Error.Conflict(); ErrorOr<string> result = "hello";
var (statusCode, _) = await ExecuteAsync(result.ToCreatedResult(value => $"/things/{value}")); var (statusCode, body) = await ExecuteAsync(result.ToHttpResult());
Assert.Equal(StatusCodes.Status409Conflict, statusCode); 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);
}
} }
} }
@@ -6,61 +6,62 @@ using Musify.Api.Filters;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
namespace Musify.Api.Tests.Filters; namespace Musify.Api.Tests.Filters
public sealed class ValidationFilterTests
{ {
public sealed record Payload(string Name); public sealed class ValidationFilterTests
// 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>>(); public sealed record Payload(string Name);
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")); // 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);
Assert.Equal("next-called", result); [Fact]
await validator.Received(1).ValidateAsync(Arg.Is<Payload>(p => p.Name == "ok"), Arg.Any<CancellationToken>()); 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"));
[Fact] var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult<object?>("next-called"));
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.Equal("next-called", result);
await validator.Received(1).ValidateAsync(Arg.Is<Payload>(p => p.Name == "ok"), Arg.Any<CancellationToken>());
}
Assert.False(nextCalled); [Fact]
var problem = Assert.IsType<ProblemHttpResult>(result); public async Task InvokeAsync_InvalidPayload_ReturnsValidationProblemWithoutCallingNext()
Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode); {
} 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;
[Fact] var result = await filter.InvokeAsync(context, _ => { nextCalled = true; return ValueTask.FromResult<object?>(null); });
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.False(nextCalled);
var problem = Assert.IsType<ProblemHttpResult>(result);
Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode);
}
Assert.Equal("next-called", result); [Fact]
await validator.DidNotReceiveWithAnyArgs().ValidateAsync(Arg.Any<Payload>(), Arg.Any<CancellationToken>()); 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>());
}
} }
} }
@@ -11,111 +11,112 @@ using NSubstitute;
using Testcontainers.PostgreSql; using Testcontainers.PostgreSql;
using Xunit; using Xunit;
namespace Musify.Api.Tests.TestSupport; 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") /// <summary>
.WithDatabase("musify_api_test") /// Boots the real <c>Musify.Api</c> host — real routing, real auth/authorization pipeline,
.WithUsername("postgres") /// real validators, real Mediator handlers — against a private Postgres container (so EF
.WithPassword("postgres") /// queries behave like production, not like a stand-in). The services this API talks to over
.Build(); /// 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
public IStorageService StorageService { get; } = Substitute.For<IStorageService>(); /// Musify.Infrastructure.Tests), not the HTTP layer's.
/// </summary>
public IEventBus EventBus { get; } = Substitute.For<IEventBus>(); public sealed class ApiTestFixture : WebApplicationFactory<Program>, IAsyncLifetime
public IYouTubeMusicService YouTubeMusicService { get; } = Substitute.For<IYouTubeMusicService>();
public IStreamTicketService StreamTicketService { get; } = Substitute.For<IStreamTicketService>();
public async ValueTask InitializeAsync()
{ {
await postgres.StartAsync(); private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine")
.WithDatabase("musify_api_test")
.WithUsername("postgres")
.WithPassword("postgres")
.Build();
using var scope = Services.CreateScope(); public IStorageService StorageService { get; } = Substitute.For<IStorageService>();
var database = scope.ServiceProvider.GetRequiredService<Musify.Infrastructure.Persistence.Database>();
await database.Database.MigrateAsync();
}
// xUnit v3's IAsyncLifetime IS System.IAsyncDisposable (rather than declaring its own public IEventBus EventBus { get; } = Substitute.For<IEventBus>();
// Task-returning DisposeAsync as in v2), so there is a single DisposeAsync slot to fill —
// override the one WebApplicationFactory already provides instead of implementing the
// interface explicitly alongside it.
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
await postgres.DisposeAsync();
}
/// <summary>An <see cref="HttpClient"/> with no auth header — every protected endpoint answers 401.</summary> public IYouTubeMusicService YouTubeMusicService { get; } = Substitute.For<IYouTubeMusicService>();
public HttpClient CreateAnonymousClient() => CreateClient();
/// <summary>An <see cref="HttpClient"/> authenticated as the given user id (see <see cref="FakeAuthenticationHandler"/>).</summary> public IStreamTicketService StreamTicketService { get; } = Substitute.For<IStreamTicketService>();
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) public async ValueTask InitializeAsync()
{
builder.ConfigureAppConfiguration((_, configuration) =>
{ {
configuration.AddInMemoryCollection(new Dictionary<string, string?> await postgres.StartAsync();
using var scope = Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Musify.Infrastructure.Persistence.Database>();
await database.Database.MigrateAsync();
}
// xUnit v3's IAsyncLifetime IS System.IAsyncDisposable (rather than declaring its own
// Task-returning DisposeAsync as in v2), so there is a single DisposeAsync slot to fill —
// override the one WebApplicationFactory already provides instead of implementing the
// interface explicitly alongside it.
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
await postgres.DisposeAsync();
}
/// <summary>An <see cref="HttpClient"/> with no auth header — every protected endpoint answers 401.</summary>
public HttpClient CreateAnonymousClient() => CreateClient();
/// <summary>An <see cref="HttpClient"/> authenticated as the given user id (see <see cref="FakeAuthenticationHandler"/>).</summary>
public HttpClient CreateAuthenticatedClient(long userId, string userName = "test-user")
{
var client = CreateClient();
client.DefaultRequestHeaders.Add(FakeAuthenticationHandler.UserIdHeader, userId.ToString());
client.DefaultRequestHeaders.Add("X-Test-User-Name", userName);
return client;
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((_, configuration) =>
{ {
["Database:ConnectionString"] = postgres.GetConnectionString(), configuration.AddInMemoryCollection(new Dictionary<string, string?>
["Authentication:MetadataAddress"] = "https://auth.musify.test/.well-known/openid-configuration", {
["Authentication:IssuerAddress"] = "https://auth.musify.test", ["Database:ConnectionString"] = postgres.GetConnectionString(),
["Authentication:AudienceAddress"] = "musify-api-test", ["Authentication:MetadataAddress"] = "https://auth.musify.test/.well-known/openid-configuration",
["Authentication:ClientId"] = "musify-api-test", ["Authentication:IssuerAddress"] = "https://auth.musify.test",
["Authentication:AuthorizationEndpoint"] = "https://auth.musify.test/authorize", ["Authentication:AudienceAddress"] = "musify-api-test",
["Authentication:TokenEndpoint"] = "https://auth.musify.test/token", ["Authentication:ClientId"] = "musify-api-test",
["Authentication:Scopes:0"] = "openid", ["Authentication:AuthorizationEndpoint"] = "https://auth.musify.test/authorize",
["StreamTicket:PrivateKeyPath"] = "unused-in-tests.pem", ["Authentication:TokenEndpoint"] = "https://auth.musify.test/token",
["OpenTelemetry:OtlpEndpoint"] = "http://localhost:4317", ["Authentication:Scopes:0"] = "openid",
["MassTransit:Address"] = "rabbitmq://localhost:5672", ["StreamTicket:PrivateKeyPath"] = "unused-in-tests.pem",
["MassTransit:Username"] = "guest", ["OpenTelemetry:OtlpEndpoint"] = "http://localhost:4317",
["MassTransit:Password"] = "guest", ["MassTransit:Address"] = "rabbitmq://localhost:5672",
["InfrastructureStorage:Address"] = "http://localhost:8333", ["MassTransit:Username"] = "guest",
["InfrastructureStorage:AccessKey"] = "unused-in-tests", ["MassTransit:Password"] = "guest",
["InfrastructureStorage:SecretAccessKey"] = "unused-in-tests", ["InfrastructureStorage:Address"] = "http://localhost:8333",
["InfrastructureStorage:ForcePathStyle"] = "true", ["InfrastructureStorage:AccessKey"] = "unused-in-tests",
["InfrastructureStorage:UseHttp"] = "true", ["InfrastructureStorage:SecretAccessKey"] = "unused-in-tests",
["ApplicationStorage:Bucket"] = "musify-api-test", ["InfrastructureStorage:ForcePathStyle"] = "true",
["StreamGateway:PublicBaseUrl"] = "http://stream.musify.test", ["InfrastructureStorage:UseHttp"] = "true",
["PlayList:SeedPresetPictures"] = "false", ["ApplicationStorage:Bucket"] = "musify-api-test",
["StreamGateway:PublicBaseUrl"] = "http://stream.musify.test",
["PlayList:SeedPresetPictures"] = "false",
});
}); });
});
builder.ConfigureTestServices(services => builder.ConfigureTestServices(services =>
{ {
services.Replace(ServiceDescriptor.Singleton(StorageService)); services.Replace(ServiceDescriptor.Singleton(StorageService));
services.Replace(ServiceDescriptor.Scoped(_ => EventBus)); services.Replace(ServiceDescriptor.Scoped(_ => EventBus));
services.Replace(ServiceDescriptor.Singleton(YouTubeMusicService)); services.Replace(ServiceDescriptor.Singleton(YouTubeMusicService));
services.Replace(ServiceDescriptor.Singleton(StreamTicketService)); services.Replace(ServiceDescriptor.Singleton(StreamTicketService));
// Overrides the real JwtBearer scheme as the default, so requests authenticate via // Overrides the real JwtBearer scheme as the default, so requests authenticate via
// FakeAuthenticationHandler instead of needing a real token and a reachable IdP. // FakeAuthenticationHandler instead of needing a real token and a reachable IdP.
services services
.AddAuthentication(FakeAuthenticationHandler.SchemeName) .AddAuthentication(FakeAuthenticationHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, FakeAuthenticationHandler>(FakeAuthenticationHandler.SchemeName, _ => { }); .AddScheme<AuthenticationSchemeOptions, FakeAuthenticationHandler>(FakeAuthenticationHandler.SchemeName, _ => { });
}); });
}
}
[CollectionDefinition(Name)]
public sealed class ApiCollection : ICollectionFixture<ApiTestFixture>
{
public const string Name = "Api";
} }
} }
[CollectionDefinition(Name)]
public sealed class ApiCollection : ICollectionFixture<ApiTestFixture>
{
public const string Name = "Api";
}
@@ -4,34 +4,35 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace Musify.Api.Tests.TestSupport; 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"; /// <summary>
public const string UserIdHeader = "X-Test-User-Id"; /// 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,
protected override Task<AuthenticateResult> HandleAuthenticateAsync() /// issuer or JWKS endpoint involved. A request without the header stays anonymous, so
/// endpoints under <c>.RequireAuthorization()</c> correctly answer 401.
/// </summary>
public sealed class FakeAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{ {
if (!Request.Headers.TryGetValue(UserIdHeader, out var userId) || string.IsNullOrEmpty(userId)) public const string SchemeName = "Test";
return Task.FromResult(AuthenticateResult.NoResult()); public const string UserIdHeader = "X-Test-User-Id";
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, userId!) }; protected override Task<AuthenticateResult> HandleAuthenticateAsync()
if (Request.Headers.TryGetValue("X-Test-User-Name", out var userName) && !string.IsNullOrEmpty(userName)) {
claims.Add(new Claim(ClaimTypes.Name, userName!)); if (!Request.Headers.TryGetValue(UserIdHeader, out var userId) || string.IsNullOrEmpty(userId))
return Task.FromResult(AuthenticateResult.NoResult());
var identity = new ClaimsIdentity(claims, SchemeName); var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, userId!) };
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName); if (Request.Headers.TryGetValue("X-Test-User-Name", out var userName) && !string.IsNullOrEmpty(userName))
return Task.FromResult(AuthenticateResult.Success(ticket)); 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));
}
} }
} }
@@ -2,74 +2,75 @@ using Musify.Api.DataTransferObjects.Albums;
using Musify.Api.Validators.Albums; using Musify.Api.Validators.Albums;
using Xunit; using Xunit;
namespace Musify.Api.Tests.Validators; namespace Musify.Api.Tests.Validators
public sealed class CreateAlbumRequestValidatorTests
{ {
private readonly CreateAlbumRequestValidator validator = new(); public sealed class CreateAlbumRequestValidatorTests
[Fact]
public void Validate_ValidRequest_Passes()
{ {
var result = validator.Validate(new CreateAlbumRequest("My Album", "A short description", 2024)); private readonly CreateAlbumRequestValidator validator = new();
Assert.True(result.IsValid); [Fact]
} public void Validate_ValidRequest_Passes()
{
var result = validator.Validate(new CreateAlbumRequest("My Album", "A short description", 2024));
[Fact] Assert.True(result.IsValid);
public void Validate_ValidRequestWithoutOptionalFields_Passes() }
{
var result = validator.Validate(new CreateAlbumRequest("My Album", null, null));
Assert.True(result.IsValid); [Fact]
} public void Validate_ValidRequestWithoutOptionalFields_Passes()
{
var result = validator.Validate(new CreateAlbumRequest("My Album", null, null));
[Fact] Assert.True(result.IsValid);
public void Validate_BlankTitle_Fails() }
{
var result = validator.Validate(new CreateAlbumRequest("", null, null));
Assert.False(result.IsValid); [Fact]
Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.Title)); public void Validate_BlankTitle_Fails()
} {
var result = validator.Validate(new CreateAlbumRequest("", null, null));
[Fact] Assert.False(result.IsValid);
public void Validate_TitleOverMaxLength_Fails() Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.Title));
{ }
var result = validator.Validate(new CreateAlbumRequest(new string('a', 201), null, null));
Assert.False(result.IsValid); [Fact]
} public void Validate_TitleOverMaxLength_Fails()
{
var result = validator.Validate(new CreateAlbumRequest(new string('a', 201), null, null));
[Fact] Assert.False(result.IsValid);
public void Validate_ReleaseYearBeforeTheEarliestBoundary_Fails() }
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest - 1));
Assert.False(result.IsValid); [Fact]
} public void Validate_ReleaseYearBeforeTheEarliestBoundary_Fails()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest - 1));
[Fact] Assert.False(result.IsValid);
public void Validate_ReleaseYearAtTheEarliestBoundary_Passes() }
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest));
Assert.True(result.IsValid); [Fact]
} public void Validate_ReleaseYearAtTheEarliestBoundary_Passes()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest));
[Fact] Assert.True(result.IsValid);
public void Validate_ReleaseYearAtTheLatestBoundary_Passes() }
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest));
Assert.True(result.IsValid); [Fact]
} public void Validate_ReleaseYearAtTheLatestBoundary_Passes()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest));
[Fact] Assert.True(result.IsValid);
public void Validate_ReleaseYearPastTheLatestBoundary_Fails() }
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest + 1));
Assert.False(result.IsValid); [Fact]
public void Validate_ReleaseYearPastTheLatestBoundary_Fails()
{
var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest + 1));
Assert.False(result.IsValid);
}
} }
} }
@@ -2,50 +2,51 @@ using Musify.Api.DataTransferObjects.Users;
using Musify.Api.Validators.Users; using Musify.Api.Validators.Users;
using Xunit; using Xunit;
namespace Musify.Api.Tests.Validators; namespace Musify.Api.Tests.Validators
public sealed class CreateUserRequestValidatorTests
{ {
private readonly CreateUserRequestValidator validator = new(); public sealed class CreateUserRequestValidatorTests
[Fact]
public void Validate_ValidRequest_Passes()
{ {
var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", "Jane", "Doe")); private readonly CreateUserRequestValidator validator = new();
Assert.True(result.IsValid); [Fact]
} public void Validate_ValidRequest_Passes()
{
var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", "Jane", "Doe"));
[Fact] Assert.True(result.IsValid);
public void Validate_ZeroId_Fails() }
{
var result = validator.Validate(new CreateUserRequest(0, "Jane Doe", null, null));
Assert.False(result.IsValid); [Fact]
Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateUserRequest.Id)); public void Validate_ZeroId_Fails()
} {
var result = validator.Validate(new CreateUserRequest(0, "Jane Doe", null, null));
[Fact] Assert.False(result.IsValid);
public void Validate_BlankName_Fails() Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateUserRequest.Id));
{ }
var result = validator.Validate(new CreateUserRequest(1, "", null, null));
Assert.False(result.IsValid); [Fact]
} public void Validate_BlankName_Fails()
{
var result = validator.Validate(new CreateUserRequest(1, "", null, null));
[Fact] Assert.False(result.IsValid);
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_NameOverMaxLength_Fails()
{
var result = validator.Validate(new CreateUserRequest(1, new string('a', 49), null, null));
[Fact] Assert.False(result.IsValid);
public void Validate_OptionalNamesOmitted_Passes() }
{
var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", null, null));
Assert.True(result.IsValid); [Fact]
public void Validate_OptionalNamesOmitted_Passes()
{
var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", null, null));
Assert.True(result.IsValid);
}
} }
} }
@@ -5,107 +5,108 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Albums; namespace Musify.Application.Tests.Albums
public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase
{ {
private AddTrackToAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<AddTrackToAlbumCommandHandler>()); public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_FirstTrack_AddsItAtNumberOne()
{ {
var owner = TestEntities.User(); private AddTrackToAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<AddTrackToAlbumCommandHandler>());
var album = TestEntities.UserAlbum(owner.Id);
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, album, track);
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, track.Id), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_FirstTrack_AddsItAtNumberOne()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, album, track);
Assert.False(result.IsError); var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, track.Id), TestContext.Current.CancellationToken);
var link = Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken));
Assert.Equal(1, link.TrackNumber);
}
[Fact] Assert.False(result.IsError);
public async Task Handle_AlbumMissing_ReturnsNotFound() var link = Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken));
{ Assert.Equal(1, link.TrackNumber);
var owner = TestEntities.User(); }
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, Guid.NewGuid(), track.Id), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, track);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, Guid.NewGuid(), track.Id), TestContext.Current.CancellationToken);
}
[Fact] Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
public async Task Handle_NotOwner_ReturnsUnauthorized() }
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var album = TestEntities.UserAlbum(owner.Id);
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, stranger, album, track);
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(stranger.Id, album.Id, track.Id), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_NotOwner_ReturnsUnauthorized()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var album = TestEntities.UserAlbum(owner.Id);
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, stranger, album, track);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(stranger.Id, album.Id, track.Id), TestContext.Current.CancellationToken);
}
[Fact] Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
public async Task Handle_TrackMissing_ReturnsNotFound() }
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
await SeedAsync(owner, album);
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_TrackMissing_ReturnsNotFound()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
await SeedAsync(owner, album);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
}
[Fact] Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
public async Task Handle_TrackOwnedByAnotherUser_ReturnsUnauthorized() }
{
var owner = TestEntities.User(1, "owner");
var otherOwner = TestEntities.User(2, "other");
var album = TestEntities.UserAlbum(owner.Id);
var foreignTrack = TestEntities.LocalTrack(otherOwner);
await SeedAsync(owner, otherOwner, album, foreignTrack);
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, foreignTrack.Id), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_TrackOwnedByAnotherUser_ReturnsUnauthorized()
{
var owner = TestEntities.User(1, "owner");
var otherOwner = TestEntities.User(2, "other");
var album = TestEntities.UserAlbum(owner.Id);
var foreignTrack = TestEntities.LocalTrack(otherOwner);
await SeedAsync(owner, otherOwner, album, foreignTrack);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, foreignTrack.Id), TestContext.Current.CancellationToken);
}
[Fact] Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
public async Task Handle_AlreadyInAlbum_ReturnsConflict() }
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var track = TestEntities.LocalTrack(owner);
var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 };
await SeedAsync(owner, album, track, link);
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, track.Id), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_AlreadyInAlbum_ReturnsConflict()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var track = TestEntities.LocalTrack(owner);
var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 };
await SeedAsync(owner, album, track, link);
Assert.Equal(ErrorType.Conflict, result.FirstError.Type); var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, track.Id), TestContext.Current.CancellationToken);
}
[Fact] Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
public async Task Handle_AlbumAlreadyHasTracks_AppendsAtNextNumber() }
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var firstTrack = TestEntities.LocalTrack(owner, "First");
var secondTrack = TestEntities.LocalTrack(owner, "Second");
var existingLink = new AlbumHasTrack { AlbumId = album.Id, TrackId = firstTrack.Id, TrackNumber = 3 };
await SeedAsync(owner, album, firstTrack, secondTrack, existingLink);
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, secondTrack.Id), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_AlbumAlreadyHasTracks_AppendsAtNextNumber()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var firstTrack = TestEntities.LocalTrack(owner, "First");
var secondTrack = TestEntities.LocalTrack(owner, "Second");
var existingLink = new AlbumHasTrack { AlbumId = album.Id, TrackId = firstTrack.Id, TrackNumber = 3 };
await SeedAsync(owner, album, firstTrack, secondTrack, existingLink);
Assert.False(result.IsError); var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, secondTrack.Id), TestContext.Current.CancellationToken);
var newLink = await Database.AlbumHasTracks.SingleAsync(l => l.TrackId == secondTrack.Id, TestContext.Current.CancellationToken);
Assert.Equal(4, newLink.TrackNumber); Assert.False(result.IsError);
var newLink = await Database.AlbumHasTracks.SingleAsync(l => l.TrackId == secondTrack.Id, TestContext.Current.CancellationToken);
Assert.Equal(4, newLink.TrackNumber);
}
} }
} }
@@ -3,41 +3,42 @@ using Musify.Application.Albums;
using Musify.Application.Tests.TestSupport; using Musify.Application.Tests.TestSupport;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Albums; namespace Musify.Application.Tests.Albums
public sealed class CreateAlbumCommandHandlerTests : HandlerTestBase
{ {
private CreateAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<CreateAlbumCommandHandler>()); public sealed class CreateAlbumCommandHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_UserExists_CreatesAlbumAndReturnsIt()
{ {
var owner = TestEntities.User(); private CreateAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<CreateAlbumCommandHandler>());
await SeedAsync(owner);
var command = new CreateAlbumCommand(owner.Id, " My Album ", "A description", 2024); [Fact]
public async Task Handle_UserExists_CreatesAlbumAndReturnsIt()
{
var owner = TestEntities.User();
await SeedAsync(owner);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); var command = new CreateAlbumCommand(owner.Id, " My Album ", "A description", 2024);
Assert.False(result.IsError); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.Equal("My Album", result.Value.Title);
Assert.Equal("A description", result.Value.Description);
Assert.Equal(2024, result.Value.ReleaseYear);
Assert.Equal(owner.Id, result.Value.OwnerUserId);
Assert.Equal(0, result.Value.TrackCount);
var stored = await Database.UserAlbums.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); Assert.False(result.IsError);
Assert.NotNull(stored); Assert.Equal("My Album", result.Value.Title);
Assert.Equal("MY ALBUM", stored.NormalizedTitle); Assert.Equal("A description", result.Value.Description);
} Assert.Equal(2024, result.Value.ReleaseYear);
Assert.Equal(owner.Id, result.Value.OwnerUserId);
Assert.Equal(0, result.Value.TrackCount);
[Fact] var stored = await Database.UserAlbums.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
public async Task Handle_UserDoesNotExist_ReturnsNotFound() Assert.NotNull(stored);
{ Assert.Equal("MY ALBUM", stored.NormalizedTitle);
var command = new CreateAlbumCommand(UserId: 404, "Orphan Album", Description: null, ReleaseYear: null); }
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); [Fact]
public async Task Handle_UserDoesNotExist_ReturnsNotFound()
{
var command = new CreateAlbumCommand(UserId: 404, "Orphan Album", Description: null, ReleaseYear: null);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
} }
} }
@@ -3,44 +3,45 @@ using Musify.Application.Albums;
using Musify.Application.Tests.TestSupport; using Musify.Application.Tests.TestSupport;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Albums; namespace Musify.Application.Tests.Albums
public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase
{ {
private DeleteAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<DeleteAlbumCommandHandler>()); public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_Owner_RemovesAlbum()
{ {
var owner = TestEntities.User(); private DeleteAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<DeleteAlbumCommandHandler>());
var album = TestEntities.UserAlbum(owner.Id);
await SeedAsync(owner, album);
var result = await CreateHandler().Handle(new DeleteAlbumCommand(owner.Id, album.Id), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_Owner_RemovesAlbum()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
await SeedAsync(owner, album);
Assert.False(result.IsError); var result = await CreateHandler().Handle(new DeleteAlbumCommand(owner.Id, album.Id), TestContext.Current.CancellationToken);
Assert.Null(await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken));
}
[Fact] Assert.False(result.IsError);
public async Task Handle_AlbumMissing_ReturnsNotFound() Assert.Null(await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken));
{ }
var result = await CreateHandler().Handle(new DeleteAlbumCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); [Fact]
} public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new DeleteAlbumCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken);
[Fact] Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
public async Task Handle_NotOwner_ReturnsUnauthorizedAndKeepsAlbum() }
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var album = TestEntities.UserAlbum(owner.Id);
await SeedAsync(owner, stranger, album);
var result = await CreateHandler().Handle(new DeleteAlbumCommand(stranger.Id, album.Id), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_NotOwner_ReturnsUnauthorizedAndKeepsAlbum()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var album = TestEntities.UserAlbum(owner.Id);
await SeedAsync(owner, stranger, album);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); var result = await CreateHandler().Handle(new DeleteAlbumCommand(stranger.Id, album.Id), TestContext.Current.CancellationToken);
Assert.NotNull(await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken));
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
Assert.NotNull(await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken));
}
} }
} }
@@ -4,33 +4,34 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Albums; namespace Musify.Application.Tests.Albums
public sealed class GetAlbumByIdQueryHandlerTests : HandlerTestBase
{ {
private GetAlbumByIdQueryHandler CreateHandler() => new(Database); public sealed class GetAlbumByIdQueryHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_ExistingAlbum_ReturnsItWithTrackCountAndCovers()
{ {
var owner = TestEntities.User(); private GetAlbumByIdQueryHandler CreateHandler() => new(Database);
var album = TestEntities.UserAlbum(owner.Id);
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, album, track, new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 });
var result = await CreateHandler().Handle(new GetAlbumByIdQuery(album.Id), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_ExistingAlbum_ReturnsItWithTrackCountAndCovers()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, album, track, new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 });
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetAlbumByIdQuery(album.Id), TestContext.Current.CancellationToken);
Assert.Equal(album.Id, result.Value.Id);
Assert.Equal(1, result.Value.TrackCount);
Assert.Equal([track.Id], result.Value.CoverTrackIds);
}
[Fact] Assert.False(result.IsError);
public async Task Handle_AlbumMissing_ReturnsNotFound() Assert.Equal(album.Id, result.Value.Id);
{ Assert.Equal(1, result.Value.TrackCount);
var result = await CreateHandler().Handle(new GetAlbumByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken); Assert.Equal([track.Id], result.Value.CoverTrackIds);
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); [Fact]
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetAlbumByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
} }
} }
@@ -4,35 +4,36 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Albums; namespace Musify.Application.Tests.Albums
public sealed class GetAlbumTracksQueryHandlerTests : HandlerTestBase
{ {
private GetAlbumTracksQueryHandler CreateHandler() => new(Database); public sealed class GetAlbumTracksQueryHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_AlbumWithTracks_ReturnsThemOrderedByTrackNumber()
{ {
var owner = TestEntities.User(); private GetAlbumTracksQueryHandler CreateHandler() => new(Database);
var album = TestEntities.UserAlbum(owner.Id);
var second = TestEntities.LocalTrack(owner, "Second");
var first = TestEntities.LocalTrack(owner, "First");
await SeedAsync(
owner, album, second, first,
new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 },
new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 });
var result = await CreateHandler().Handle(new GetAlbumTracksQuery(album.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_AlbumWithTracks_ReturnsThemOrderedByTrackNumber()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var second = TestEntities.LocalTrack(owner, "Second");
var first = TestEntities.LocalTrack(owner, "First");
await SeedAsync(
owner, album, second, first,
new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 },
new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 });
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetAlbumTracksQuery(album.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title));
}
[Fact] Assert.False(result.IsError);
public async Task Handle_AlbumMissing_ReturnsNotFound() Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title));
{ }
var result = await CreateHandler().Handle(new GetAlbumTracksQuery(Guid.NewGuid(), PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); [Fact]
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetAlbumTracksQuery(Guid.NewGuid(), PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
} }
} }
@@ -3,31 +3,32 @@ using Musify.Application.Albums;
using Musify.Application.Tests.TestSupport; using Musify.Application.Tests.TestSupport;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Albums; namespace Musify.Application.Tests.Albums
public sealed class GetAlbumsByUserIdQueryHandlerTests : HandlerTestBase
{ {
private GetAlbumsByUserIdQueryHandler CreateHandler() => new(Database); public sealed class GetAlbumsByUserIdQueryHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_UserWithAlbums_ReturnsOnlyTheirAlbums()
{ {
var owner = TestEntities.User(1, "owner"); private GetAlbumsByUserIdQueryHandler CreateHandler() => new(Database);
var other = TestEntities.User(2, "other");
await SeedAsync(owner, other, TestEntities.UserAlbum(owner.Id, "Mine"), TestEntities.UserAlbum(other.Id, "Theirs"));
var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(owner.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_UserWithAlbums_ReturnsOnlyTheirAlbums()
{
var owner = TestEntities.User(1, "owner");
var other = TestEntities.User(2, "other");
await SeedAsync(owner, other, TestEntities.UserAlbum(owner.Id, "Mine"), TestEntities.UserAlbum(other.Id, "Theirs"));
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(owner.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
var album = Assert.Single(result.Value.Items);
Assert.Equal("Mine", album.Title);
}
[Fact] Assert.False(result.IsError);
public async Task Handle_UserMissing_ReturnsNotFound() var album = Assert.Single(result.Value.Items);
{ Assert.Equal("Mine", album.Title);
var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(404, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); }
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); [Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(404, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
} }
} }
@@ -2,47 +2,48 @@ using Musify.Application.Albums;
using Musify.Application.Tests.TestSupport; using Musify.Application.Tests.TestSupport;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Albums; namespace Musify.Application.Tests.Albums
public sealed class GetAlbumsQueryHandlerTests : HandlerTestBase
{ {
private GetAlbumsQueryHandler CreateHandler() => new(Database); public sealed class GetAlbumsQueryHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_NoTitleFilter_ReturnsAllAlbumsPaged()
{ {
var owner = TestEntities.User(); private GetAlbumsQueryHandler CreateHandler() => new(Database);
await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "Alpha"), TestEntities.UserAlbum(owner.Id, "Beta"));
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_NoTitleFilter_ReturnsAllAlbumsPaged()
{
var owner = TestEntities.User();
await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "Alpha"), TestEntities.UserAlbum(owner.Id, "Beta"));
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.Equal(2, result.Value.TotalItemCount);
}
[Fact] Assert.False(result.IsError);
public async Task Handle_TitleFilter_ReturnsOnlyMatchingAlbums() Assert.Equal(2, result.Value.TotalItemCount);
{ }
var owner = TestEntities.User();
await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "Rock Classics"), TestEntities.UserAlbum(owner.Id, "Jazz Favorites"));
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_TitleFilter_ReturnsOnlyMatchingAlbums()
{
var owner = TestEntities.User();
await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "Rock Classics"), TestEntities.UserAlbum(owner.Id, "Jazz Favorites"));
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
var album = Assert.Single(result.Value.Items);
Assert.Equal("Rock Classics", album.Title);
}
[Fact] Assert.False(result.IsError);
public async Task Handle_PageSizeSmallerThanTotal_ReportsHasNextPage() var album = Assert.Single(result.Value.Items);
{ Assert.Equal("Rock Classics", album.Title);
var owner = TestEntities.User(); }
await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "One"), TestEntities.UserAlbum(owner.Id, "Two"), TestEntities.UserAlbum(owner.Id, "Three"));
var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 2), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_PageSizeSmallerThanTotal_ReportsHasNextPage()
{
var owner = TestEntities.User();
await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "One"), TestEntities.UserAlbum(owner.Id, "Two"), TestEntities.UserAlbum(owner.Id, "Three"));
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 2), TestContext.Current.CancellationToken);
Assert.Equal(2, result.Value.Items.Count);
Assert.True(result.Value.HasNextPage); Assert.False(result.IsError);
Assert.Equal(2, result.Value.Items.Count);
Assert.True(result.Value.HasNextPage);
}
} }
} }
@@ -3,58 +3,59 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Albums; namespace Musify.Application.Tests.Albums
public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase
{ {
private GetRecentlyListenedAlbumsQueryHandler CreateHandler() => new(Database); public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_ListenedTracks_ReturnsTheirAlbumsMostRecentFirst()
{ {
var owner = TestEntities.User(); private GetRecentlyListenedAlbumsQueryHandler CreateHandler() => new(Database);
var olderAlbum = TestEntities.UserAlbum(owner.Id, "Older");
var newerAlbum = TestEntities.UserAlbum(owner.Id, "Newer");
var olderTrack = TestEntities.LocalTrack(owner, "In older album");
var newerTrack = TestEntities.LocalTrack(owner, "In newer album");
await SeedAsync(
owner, olderAlbum, newerAlbum, olderTrack, newerTrack,
new AlbumHasTrack { AlbumId = olderAlbum.Id, TrackId = olderTrack.Id, TrackNumber = 1 },
new AlbumHasTrack { AlbumId = newerAlbum.Id, TrackId = newerTrack.Id, TrackNumber = 1 },
TestEntities.ListeningHistory(owner.Id, olderTrack.Id, DateTime.UtcNow.AddDays(-2)),
TestEntities.ListeningHistory(owner.Id, newerTrack.Id, DateTime.UtcNow.AddDays(-1)));
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_ListenedTracks_ReturnsTheirAlbumsMostRecentFirst()
{
var owner = TestEntities.User();
var olderAlbum = TestEntities.UserAlbum(owner.Id, "Older");
var newerAlbum = TestEntities.UserAlbum(owner.Id, "Newer");
var olderTrack = TestEntities.LocalTrack(owner, "In older album");
var newerTrack = TestEntities.LocalTrack(owner, "In newer album");
await SeedAsync(
owner, olderAlbum, newerAlbum, olderTrack, newerTrack,
new AlbumHasTrack { AlbumId = olderAlbum.Id, TrackId = olderTrack.Id, TrackNumber = 1 },
new AlbumHasTrack { AlbumId = newerAlbum.Id, TrackId = newerTrack.Id, TrackNumber = 1 },
TestEntities.ListeningHistory(owner.Id, olderTrack.Id, DateTime.UtcNow.AddDays(-2)),
TestEntities.ListeningHistory(owner.Id, newerTrack.Id, DateTime.UtcNow.AddDays(-1)));
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
Assert.Equal(["Newer", "Older"], result.Value.Select(album => album.Title));
}
[Fact] Assert.False(result.IsError);
public async Task Handle_NoListeningHistory_ReturnsEmptyList() Assert.Equal(["Newer", "Older"], result.Value.Select(album => album.Title));
{ }
var owner = TestEntities.User();
await SeedAsync(owner);
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_NoListeningHistory_ReturnsEmptyList()
{
var owner = TestEntities.User();
await SeedAsync(owner);
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
Assert.Empty(result.Value);
}
[Fact] Assert.False(result.IsError);
public async Task Handle_LimitLowerThanCandidates_ReturnsOnlyThatManyAlbums() Assert.Empty(result.Value);
{ }
var owner = TestEntities.User();
var albums = Enumerable.Range(1, 3).Select(i => TestEntities.UserAlbum(owner.Id, $"Album {i}")).ToList();
var tracks = albums.Select((_, i) => TestEntities.LocalTrack(owner, $"Track {i}")).ToList();
var links = albums.Zip(tracks, (album, track) => new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }).ToList();
var histories = tracks.Select((track, i) => TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddMinutes(-i))).ToList();
await SeedAsync([owner, .. albums, .. tracks, .. links, .. histories]);
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 2), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_LimitLowerThanCandidates_ReturnsOnlyThatManyAlbums()
{
var owner = TestEntities.User();
var albums = Enumerable.Range(1, 3).Select(i => TestEntities.UserAlbum(owner.Id, $"Album {i}")).ToList();
var tracks = albums.Select((_, i) => TestEntities.LocalTrack(owner, $"Track {i}")).ToList();
var links = albums.Zip(tracks, (album, track) => new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }).ToList();
var histories = tracks.Select((track, i) => TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddMinutes(-i))).ToList();
await SeedAsync([owner, .. albums, .. tracks, .. links, .. histories]);
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 2), TestContext.Current.CancellationToken);
Assert.Equal(2, result.Value.Count);
Assert.False(result.IsError);
Assert.Equal(2, result.Value.Count);
}
} }
} }
@@ -5,68 +5,69 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Albums; namespace Musify.Application.Tests.Albums
public sealed class RemoveTrackFromAlbumCommandHandlerTests : HandlerTestBase
{ {
private RemoveTrackFromAlbumCommandHandler CreateHandler() => public sealed class RemoveTrackFromAlbumCommandHandlerTests : HandlerTestBase
new(Database, NoOpLogger<RemoveTrackFromAlbumCommandHandler>());
[Fact]
public async Task Handle_TrackInAlbum_RemovesItAndRenumbersTheRest()
{ {
var owner = TestEntities.User(); private RemoveTrackFromAlbumCommandHandler CreateHandler() =>
var album = TestEntities.UserAlbum(owner.Id); new(Database, NoOpLogger<RemoveTrackFromAlbumCommandHandler>());
var first = TestEntities.LocalTrack(owner, "First");
var second = TestEntities.LocalTrack(owner, "Second");
var third = TestEntities.LocalTrack(owner, "Third");
await SeedAsync(
owner, album, first, second, third,
new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 },
new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 },
new AlbumHasTrack { AlbumId = album.Id, TrackId = third.Id, TrackNumber = 3 });
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, second.Id), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_TrackInAlbum_RemovesItAndRenumbersTheRest()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
var first = TestEntities.LocalTrack(owner, "First");
var second = TestEntities.LocalTrack(owner, "Second");
var third = TestEntities.LocalTrack(owner, "Third");
await SeedAsync(
owner, album, first, second, third,
new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 },
new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 },
new AlbumHasTrack { AlbumId = album.Id, TrackId = third.Id, TrackNumber = 3 });
Assert.False(result.IsError); var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, second.Id), TestContext.Current.CancellationToken);
var remaining = await Database.AlbumHasTracks
.Where(link => link.AlbumId == album.Id)
.OrderBy(link => link.TrackNumber)
.ToListAsync(TestContext.Current.CancellationToken);
Assert.Equal([first.Id, third.Id], remaining.Select(link => link.TrackId));
Assert.Equal([1, 2], remaining.Select(link => link.TrackNumber));
}
[Fact] Assert.False(result.IsError);
public async Task Handle_AlbumMissing_ReturnsNotFound() var remaining = await Database.AlbumHasTracks
{ .Where(link => link.AlbumId == album.Id)
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken); .OrderBy(link => link.TrackNumber)
.ToListAsync(TestContext.Current.CancellationToken);
Assert.Equal([first.Id, third.Id], remaining.Select(link => link.TrackId));
Assert.Equal([1, 2], remaining.Select(link => link.TrackNumber));
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); [Fact]
} public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken);
[Fact] Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
public async Task Handle_NotOwner_ReturnsUnauthorized() }
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var album = TestEntities.UserAlbum(owner.Id);
await SeedAsync(owner, stranger, album);
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(stranger.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_NotOwner_ReturnsUnauthorized()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var album = TestEntities.UserAlbum(owner.Id);
await SeedAsync(owner, stranger, album);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(stranger.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
}
[Fact] Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
public async Task Handle_TrackNotInAlbum_ReturnsNotFound() }
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
await SeedAsync(owner, album);
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_TrackNotInAlbum_ReturnsNotFound()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id);
await SeedAsync(owner, album);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
} }
} }
@@ -3,55 +3,56 @@ using Musify.Application.Albums;
using Musify.Application.Tests.TestSupport; using Musify.Application.Tests.TestSupport;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Albums; namespace Musify.Application.Tests.Albums
public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase
{ {
private UpdateAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<UpdateAlbumCommandHandler>()); public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear()
{ {
var owner = TestEntities.User(); private UpdateAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<UpdateAlbumCommandHandler>());
var album = TestEntities.UserAlbum(owner.Id, title: "Old Title", description: "Old", releaseYear: 2020);
await SeedAsync(owner, album);
var command = new UpdateAlbumCommand(owner.Id, album.Id, " New Title ", "New description", 2025); [Fact]
public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear()
{
var owner = TestEntities.User();
var album = TestEntities.UserAlbum(owner.Id, title: "Old Title", description: "Old", releaseYear: 2020);
await SeedAsync(owner, album);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); var command = new UpdateAlbumCommand(owner.Id, album.Id, " New Title ", "New description", 2025);
Assert.False(result.IsError); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.Equal("New Title", result.Value.Title);
Assert.Equal("New description", result.Value.Description);
Assert.Equal(2025, result.Value.ReleaseYear);
var stored = await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken); Assert.False(result.IsError);
Assert.NotNull(stored); Assert.Equal("New Title", result.Value.Title);
Assert.Equal("NEW TITLE", stored.NormalizedTitle); Assert.Equal("New description", result.Value.Description);
} Assert.Equal(2025, result.Value.ReleaseYear);
[Fact] var stored = await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken);
public async Task Handle_AlbumMissing_ReturnsNotFound() Assert.NotNull(stored);
{ Assert.Equal("NEW TITLE", stored.NormalizedTitle);
var command = new UpdateAlbumCommand(1, Guid.NewGuid(), "Title", null, null); }
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); [Fact]
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var command = new UpdateAlbumCommand(1, Guid.NewGuid(), "Title", null, null);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
}
[Fact] Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
public async Task Handle_NotOwner_ReturnsUnauthorized() }
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var album = TestEntities.UserAlbum(owner.Id);
await SeedAsync(owner, stranger, album);
var command = new UpdateAlbumCommand(stranger.Id, album.Id, "Hijacked", null, null); [Fact]
public async Task Handle_NotOwner_ReturnsUnauthorized()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var album = TestEntities.UserAlbum(owner.Id);
await SeedAsync(owner, stranger, album);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); var command = new UpdateAlbumCommand(stranger.Id, album.Id, "Hijacked", null, null);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
}
} }
} }
@@ -8,99 +8,100 @@ using Musify.Domain.ValueObjects;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Mixes; namespace Musify.Application.Tests.Mixes
public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase
{ {
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>(); public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase
private GenerateMixesForUserCommandHandler CreateHandler() => new(
Database, youTubeMusicService, TestConfigurations.Mix(), NoOpLogger<GenerateMixesForUserCommandHandler>());
[Fact]
public async Task Handle_NoListeningHistory_DoesNotCreateAnyMix()
{ {
var user = TestEntities.User(); private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
await SeedAsync(user);
var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); private GenerateMixesForUserCommandHandler CreateHandler() => new(
Database, youTubeMusicService, TestConfigurations.Mix(), NoOpLogger<GenerateMixesForUserCommandHandler>());
Assert.False(result.IsError); [Fact]
Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken)); public async Task Handle_NoListeningHistory_DoesNotCreateAnyMix()
} {
var user = TestEntities.User();
await SeedAsync(user);
[Fact] var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
public async Task Handle_HistoryWithNoKnownArtists_DoesNotCreateAnyMix()
{
var user = TestEntities.User();
var track = TestEntities.LocalTrack(user);
await SeedAsync(user, track, TestEntities.ListeningHistory(user.Id, track.Id));
var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError);
Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken));
}
Assert.False(result.IsError); [Fact]
Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken)); public async Task Handle_HistoryWithNoKnownArtists_DoesNotCreateAnyMix()
} {
var user = TestEntities.User();
var track = TestEntities.LocalTrack(user);
await SeedAsync(user, track, TestEntities.ListeningHistory(user.Id, track.Id));
[Fact] var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
public async Task Handle_SeedArtistWithYouTubeAndLocalCandidates_ReplacesPreviousMixes()
{
youTubeMusicService
.SearchSongsAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new YouTubeSearchResult(
[new YouTubeSongResult("yt-1", "YouTube Song", "Seed Artist", "Album", 200, "https://img/thumb.jpg", IsExplicit: false, [] )],
ContinuationToken: string.Empty));
youTubeMusicService.ResolveArtworkUrl(Arg.Any<string>()).Returns(callInfo => callInfo.Arg<string>());
var user = TestEntities.User(); Assert.False(result.IsError);
var artist = TestEntities.Artist("Seed Artist"); Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken));
var listenedTrack = TestEntities.ExternalTrack(externalId: "listened", title: "Listened Track"); }
var otherTrackByArtist = TestEntities.ExternalTrack(externalId: "not-listened", title: "Other Track");
var staleMix = TestEntities.Mix(user.Id, "Stale Mix"); [Fact]
var staleItem = TestEntities.MixItem(staleMix.Id, title: "Stale Item"); public async Task Handle_SeedArtistWithYouTubeAndLocalCandidates_ReplacesPreviousMixes()
{
youTubeMusicService
.SearchSongsAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new YouTubeSearchResult(
[new YouTubeSongResult("yt-1", "YouTube Song", "Seed Artist", "Album", 200, "https://img/thumb.jpg", IsExplicit: false, [] )],
ContinuationToken: string.Empty));
youTubeMusicService.ResolveArtworkUrl(Arg.Any<string>()).Returns(callInfo => callInfo.Arg<string>());
await SeedAsync( var user = TestEntities.User();
user, artist, listenedTrack, otherTrackByArtist, staleMix, staleItem, var artist = TestEntities.Artist("Seed Artist");
new TrackArtist { TrackId = listenedTrack.Id, ArtistId = artist.Id, Position = 0 }, var listenedTrack = TestEntities.ExternalTrack(externalId: "listened", title: "Listened Track");
new TrackArtist { TrackId = otherTrackByArtist.Id, ArtistId = artist.Id, Position = 0 }, var otherTrackByArtist = TestEntities.ExternalTrack(externalId: "not-listened", title: "Other Track");
TestEntities.ListeningHistory(user.Id, listenedTrack.Id));
var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); var staleMix = TestEntities.Mix(user.Id, "Stale Mix");
var staleItem = TestEntities.MixItem(staleMix.Id, title: "Stale Item");
Assert.False(result.IsError); await SeedAsync(
var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); user, artist, listenedTrack, otherTrackByArtist, staleMix, staleItem,
Assert.NotEmpty(mixes); new TrackArtist { TrackId = listenedTrack.Id, ArtistId = artist.Id, Position = 0 },
Assert.DoesNotContain(mixes, mix => mix.Title == "Stale Mix"); new TrackArtist { TrackId = otherTrackByArtist.Id, ArtistId = artist.Id, Position = 0 },
Assert.Empty(await Database.MixItems.Where(item => item.MixId == staleMix.Id).ToListAsync(TestContext.Current.CancellationToken)); TestEntities.ListeningHistory(user.Id, listenedTrack.Id));
}
[Fact] var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
public async Task Handle_YouTubeSourceUnavailable_StillCreatesMixesFromLocalCandidatesOnly()
{
// Simulates IYouTubeMusicService.SearchSongsAsync as it behaves when the YouTube source is
// disabled (DisabledYouTubeMusicService, see Infrastructure): every call fails. Mix
// generation must degrade to local-only candidates instead of failing the whole command.
youTubeMusicService
.SearchSongsAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Error.Forbidden(description: "The YouTube Music source is disabled."));
var user = TestEntities.User(); Assert.False(result.IsError);
var artist = TestEntities.Artist("Seed Artist"); var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken);
var listenedTrack = TestEntities.ExternalTrack(externalId: "listened", title: "Listened Track"); Assert.NotEmpty(mixes);
var otherTrackByArtist = TestEntities.ExternalTrack(externalId: "not-listened", title: "Other Track"); Assert.DoesNotContain(mixes, mix => mix.Title == "Stale Mix");
Assert.Empty(await Database.MixItems.Where(item => item.MixId == staleMix.Id).ToListAsync(TestContext.Current.CancellationToken));
}
await SeedAsync( [Fact]
user, artist, listenedTrack, otherTrackByArtist, public async Task Handle_YouTubeSourceUnavailable_StillCreatesMixesFromLocalCandidatesOnly()
new TrackArtist { TrackId = listenedTrack.Id, ArtistId = artist.Id, Position = 0 }, {
new TrackArtist { TrackId = otherTrackByArtist.Id, ArtistId = artist.Id, Position = 0 }, // Simulates IYouTubeMusicService.SearchSongsAsync as it behaves when the YouTube source is
TestEntities.ListeningHistory(user.Id, listenedTrack.Id)); // disabled (DisabledYouTubeMusicService, see Infrastructure): every call fails. Mix
// generation must degrade to local-only candidates instead of failing the whole command.
youTubeMusicService
.SearchSongsAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Error.Forbidden(description: "The YouTube Music source is disabled."));
var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); var user = TestEntities.User();
var artist = TestEntities.Artist("Seed Artist");
var listenedTrack = TestEntities.ExternalTrack(externalId: "listened", title: "Listened Track");
var otherTrackByArtist = TestEntities.ExternalTrack(externalId: "not-listened", title: "Other Track");
Assert.False(result.IsError); await SeedAsync(
var items = await Database.MixItems.ToListAsync(TestContext.Current.CancellationToken); user, artist, listenedTrack, otherTrackByArtist,
Assert.NotEmpty(items); new TrackArtist { TrackId = listenedTrack.Id, ArtistId = artist.Id, Position = 0 },
Assert.All(items, item => Assert.Equal(MixItemSource.Musify, item.Source)); new TrackArtist { TrackId = otherTrackByArtist.Id, ArtistId = artist.Id, Position = 0 },
TestEntities.ListeningHistory(user.Id, listenedTrack.Id));
var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var items = await Database.MixItems.ToListAsync(TestContext.Current.CancellationToken);
Assert.NotEmpty(items);
Assert.All(items, item => Assert.Equal(MixItemSource.Musify, item.Source));
}
} }
} }
@@ -3,45 +3,46 @@ using Musify.Application.Mixes;
using Musify.Application.Tests.TestSupport; using Musify.Application.Tests.TestSupport;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Mixes; namespace Musify.Application.Tests.Mixes
public sealed class GetMixByIdQueryHandlerTests : HandlerTestBase
{ {
private GetMixByIdQueryHandler CreateHandler() => new(Database); public sealed class GetMixByIdQueryHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_OwnedMix_ReturnsItWithOrderedItems()
{ {
var owner = TestEntities.User(); private GetMixByIdQueryHandler CreateHandler() => new(Database);
var mix = TestEntities.Mix(owner.Id, "Discovery");
var second = TestEntities.MixItem(mix.Id, position: 1, title: "Second");
var first = TestEntities.MixItem(mix.Id, position: 0, title: "First");
await SeedAsync(owner, mix, second, first);
var result = await CreateHandler().Handle(new GetMixByIdQuery(owner.Id, mix.Id), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_OwnedMix_ReturnsItWithOrderedItems()
{
var owner = TestEntities.User();
var mix = TestEntities.Mix(owner.Id, "Discovery");
var second = TestEntities.MixItem(mix.Id, position: 1, title: "Second");
var first = TestEntities.MixItem(mix.Id, position: 0, title: "First");
await SeedAsync(owner, mix, second, first);
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetMixByIdQuery(owner.Id, mix.Id), TestContext.Current.CancellationToken);
Assert.Equal("Discovery", result.Value.Title);
Assert.Equal(["First", "Second"], result.Value.Items.Select(item => item.Title));
}
[Fact] Assert.False(result.IsError);
public async Task Handle_MixMissing_ReturnsNotFound() Assert.Equal("Discovery", result.Value.Title);
{ Assert.Equal(["First", "Second"], result.Value.Items.Select(item => item.Title));
var result = await CreateHandler().Handle(new GetMixByIdQuery(1, Guid.NewGuid()), TestContext.Current.CancellationToken); }
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); [Fact]
} public async Task Handle_MixMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetMixByIdQuery(1, Guid.NewGuid()), TestContext.Current.CancellationToken);
[Fact] Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
public async Task Handle_MixBelongsToAnotherUser_ReturnsNotFound() }
{
var owner = TestEntities.User(1, "owner");
var mix = TestEntities.Mix(owner.Id);
await SeedAsync(owner, mix);
var result = await CreateHandler().Handle(new GetMixByIdQuery(2, mix.Id), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_MixBelongsToAnotherUser_ReturnsNotFound()
{
var owner = TestEntities.User(1, "owner");
var mix = TestEntities.Mix(owner.Id);
await SeedAsync(owner, mix);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); var result = await CreateHandler().Handle(new GetMixByIdQuery(2, mix.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
} }
} }
@@ -2,37 +2,38 @@ using Musify.Application.Mixes;
using Musify.Application.Tests.TestSupport; using Musify.Application.Tests.TestSupport;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Mixes; namespace Musify.Application.Tests.Mixes
public sealed class GetMixesByUserIdQueryHandlerTests : HandlerTestBase
{ {
private GetMixesByUserIdQueryHandler CreateHandler() => new(Database); public sealed class GetMixesByUserIdQueryHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_ReturnsOnlyThatUsersMixesOrderedByPosition()
{ {
var owner = TestEntities.User(1, "owner"); private GetMixesByUserIdQueryHandler CreateHandler() => new(Database);
var other = TestEntities.User(2, "other");
var second = TestEntities.Mix(owner.Id, "Second", position: 1);
var first = TestEntities.Mix(owner.Id, "First", position: 0);
var theirs = TestEntities.Mix(other.Id, "Theirs");
await SeedAsync(owner, other, second, first, theirs);
var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_ReturnsOnlyThatUsersMixesOrderedByPosition()
{
var owner = TestEntities.User(1, "owner");
var other = TestEntities.User(2, "other");
var second = TestEntities.Mix(owner.Id, "Second", position: 1);
var first = TestEntities.Mix(owner.Id, "First", position: 0);
var theirs = TestEntities.Mix(other.Id, "Theirs");
await SeedAsync(owner, other, second, first, theirs);
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
Assert.Equal(["First", "Second"], result.Value.Select(mix => mix.Title));
}
[Fact] Assert.False(result.IsError);
public async Task Handle_NoMixes_ReturnsEmptyList() Assert.Equal(["First", "Second"], result.Value.Select(mix => mix.Title));
{ }
var owner = TestEntities.User();
await SeedAsync(owner);
var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_NoMixes_ReturnsEmptyList()
{
var owner = TestEntities.User();
await SeedAsync(owner);
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
Assert.Empty(result.Value);
Assert.False(result.IsError);
Assert.Empty(result.Value);
}
} }
} }
@@ -5,88 +5,89 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Xunit; using Xunit;
namespace Musify.Application.Tests.PlayLists; namespace Musify.Application.Tests.PlayLists
public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase
{ {
private AddTrackToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger<AddTrackToPlayListCommandHandler>()); public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_FirstTrack_AddsItAtPositionZero()
{ {
var owner = TestEntities.User(); private AddTrackToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger<AddTrackToPlayListCommandHandler>());
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, playList, track);
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_FirstTrack_AddsItAtPositionZero()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, playList, track);
Assert.False(result.IsError); var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken);
var link = Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken));
Assert.Equal(0, link.Position);
}
[Fact] Assert.False(result.IsError);
public async Task Handle_PlayListAlreadyHasTracks_AppendsAtNextPosition() var link = Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken));
{ Assert.Equal(0, link.Position);
var owner = TestEntities.User(); }
var playList = TestEntities.PlayList(owner.Id);
var existingTrack = TestEntities.LocalTrack(owner, "Existing");
var newTrack = TestEntities.LocalTrack(owner, "New");
await SeedAsync(
owner, playList, existingTrack, newTrack,
new PlayListHasTrack { PlayListId = playList.Id, TrackId = existingTrack.Id, Position = 0 });
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, newTrack.Id), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_PlayListAlreadyHasTracks_AppendsAtNextPosition()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var existingTrack = TestEntities.LocalTrack(owner, "Existing");
var newTrack = TestEntities.LocalTrack(owner, "New");
await SeedAsync(
owner, playList, existingTrack, newTrack,
new PlayListHasTrack { PlayListId = playList.Id, TrackId = existingTrack.Id, Position = 0 });
Assert.False(result.IsError); var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, newTrack.Id), TestContext.Current.CancellationToken);
var newLink = await Database.PlayListHasTracks.SingleAsync(link => link.TrackId == newTrack.Id, TestContext.Current.CancellationToken);
Assert.Equal(1, newLink.Position);
}
[Fact] Assert.False(result.IsError);
public async Task Handle_PlayListMissing_ReturnsNotFound() var newLink = await Database.PlayListHasTracks.SingleAsync(link => link.TrackId == newTrack.Id, TestContext.Current.CancellationToken);
{ Assert.Equal(1, newLink.Position);
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken); }
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); [Fact]
} public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken);
[Fact] Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
public async Task Handle_NotOwner_ReturnsUnauthorized() }
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, stranger, playList);
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_NotOwner_ReturnsUnauthorized()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, stranger, playList);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
}
[Fact] Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
public async Task Handle_TrackMissing_ReturnsNotFound() }
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_TrackMissing_ReturnsNotFound()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
}
[Fact] Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
public async Task Handle_TrackAlreadyInPlayList_ReturnsConflict() }
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_TrackAlreadyInPlayList_ReturnsConflict()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
Assert.Equal(ErrorType.Conflict, result.FirstError.Type); var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
}
} }
} }
@@ -8,97 +8,98 @@ using Musify.Domain.Entities;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
namespace Musify.Application.Tests.PlayLists; namespace Musify.Application.Tests.PlayLists
public sealed class AddYouTubeTrackToPlayListCommandHandlerTests : HandlerTestBase
{ {
private readonly IEventBus eventBus = Substitute.For<IEventBus>(); public sealed class AddYouTubeTrackToPlayListCommandHandlerTests : HandlerTestBase
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
private AddYouTubeTrackToPlayListCommandHandler CreateHandler()
{ {
var provisioner = new YouTubeTrackProvisioner( private readonly IEventBus eventBus = Substitute.For<IEventBus>();
Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>()); private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
return new AddYouTubeTrackToPlayListCommandHandler( private AddYouTubeTrackToPlayListCommandHandler CreateHandler()
Database, {
eventBus, var provisioner = new YouTubeTrackProvisioner(
youTubeMusicService, Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
provisioner,
TestConfigurations.Storage(),
TestConfigurations.Track(),
NoOpLogger<AddYouTubeTrackToPlayListCommandHandler>());
}
private static YouTubeSongResult BuildSong(string videoId) => new( return new AddYouTubeTrackToPlayListCommandHandler(
videoId, "Song Title", "Song Artist", "Song Album", 200, "https://img.example/thumb.jpg", false, Database,
[new YouTubeArtistRef("artist-id", "Song Artist")]); eventBus,
youTubeMusicService,
provisioner,
TestConfigurations.Storage(),
TestConfigurations.Track(),
NoOpLogger<AddYouTubeTrackToPlayListCommandHandler>());
}
[Fact] private static YouTubeSongResult BuildSong(string videoId) => new(
public async Task Handle_TrackAlreadyProcessedAndNotLinked_AddsItWithoutQueuingDownload() videoId, "Song Title", "Song Artist", "Song Album", 200, "https://img.example/thumb.jpg", false,
{ [new YouTubeArtistRef("artist-id", "Song Artist")]);
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.ExternalTrack(externalId: "abc123");
await SeedAsync(owner, playList, track);
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "abc123"), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_TrackAlreadyProcessedAndNotLinked_AddsItWithoutQueuingDownload()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.ExternalTrack(externalId: "abc123");
await SeedAsync(owner, playList, track);
Assert.False(result.IsError); var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "abc123"), TestContext.Current.CancellationToken);
Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken));
await eventBus.DidNotReceive().PublishAsync(Arg.Any<Musify.Application.Events.DownloadYouTubeTrackEvent>(), Arg.Any<CancellationToken>());
}
[Fact] Assert.False(result.IsError);
public async Task Handle_UnknownVideo_ProvisionsTrackAndQueuesDownload() Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken));
{ await eventBus.DidNotReceive().PublishAsync(Arg.Any<Musify.Application.Events.DownloadYouTubeTrackEvent>(), Arg.Any<CancellationToken>());
youTubeMusicService.GetSongAsync("new-video", Arg.Any<CancellationToken>()).Returns(BuildSong("new-video")); }
youTubeMusicService.ResolveArtworkUrl(Arg.Any<string>()).Returns(callInfo => callInfo.Arg<string>());
var owner = TestEntities.User(); [Fact]
var playList = TestEntities.PlayList(owner.Id); public async Task Handle_UnknownVideo_ProvisionsTrackAndQueuesDownload()
await SeedAsync(owner, playList); {
youTubeMusicService.GetSongAsync("new-video", Arg.Any<CancellationToken>()).Returns(BuildSong("new-video"));
youTubeMusicService.ResolveArtworkUrl(Arg.Any<string>()).Returns(callInfo => callInfo.Arg<string>());
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "new-video"), TestContext.Current.CancellationToken); var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
Assert.False(result.IsError); var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "new-video"), TestContext.Current.CancellationToken);
Assert.Equal("Song Title", result.Value.Title);
var track = await Database.ExternalTracks.SingleAsync(t => t.ExternalId == "new-video", TestContext.Current.CancellationToken);
Assert.True(track.Audio.DownloadRequested);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.DownloadYouTubeTrackEvent>(), Arg.Any<CancellationToken>());
}
[Fact] Assert.False(result.IsError);
public async Task Handle_PlayListMissing_ReturnsNotFound() Assert.Equal("Song Title", result.Value.Title);
{ var track = await Database.ExternalTracks.SingleAsync(t => t.ExternalId == "new-video", TestContext.Current.CancellationToken);
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(1, Guid.NewGuid(), "abc123"), TestContext.Current.CancellationToken); Assert.True(track.Audio.DownloadRequested);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.DownloadYouTubeTrackEvent>(), Arg.Any<CancellationToken>());
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); [Fact]
} public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(1, Guid.NewGuid(), "abc123"), TestContext.Current.CancellationToken);
[Fact] Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
public async Task Handle_NotOwner_ReturnsUnauthorized() }
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, stranger, playList);
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(stranger.Id, playList.Id, "abc123"), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_NotOwner_ReturnsUnauthorized()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, stranger, playList);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(stranger.Id, playList.Id, "abc123"), TestContext.Current.CancellationToken);
}
[Fact] Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
public async Task Handle_TrackAlreadyLinked_ReturnsConflict() }
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.ExternalTrack(externalId: "abc123");
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "abc123"), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_TrackAlreadyLinked_ReturnsConflict()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.ExternalTrack(externalId: "abc123");
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
Assert.Equal(ErrorType.Conflict, result.FirstError.Type); var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "abc123"), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
}
} }
} }
@@ -6,80 +6,81 @@ using Musify.Application.Tests.TestSupport;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
namespace Musify.Application.Tests.PlayLists; namespace Musify.Application.Tests.PlayLists
public sealed class CreatePlayListCommandHandlerTests : HandlerTestBase
{ {
private readonly IEventBus eventBus = Substitute.For<IEventBus>(); public sealed class CreatePlayListCommandHandlerTests : HandlerTestBase
private readonly IStorageService storageService = Substitute.For<IStorageService>();
private CreatePlayListCommandHandler CreateHandler() => new(
eventBus,
Database,
new UploadIntentValidator(Database, storageService),
NoOpLogger<CreatePlayListCommandHandler>(),
TestConfigurations.Storage(),
TestConfigurations.PlayList(),
TestConfigurations.UploadIntent());
[Fact]
public async Task Handle_NoPictureIntent_CreatesPlayListWithPresetPictures()
{ {
var user = TestEntities.User(); private readonly IEventBus eventBus = Substitute.For<IEventBus>();
await SeedAsync(user); private readonly IStorageService storageService = Substitute.For<IStorageService>();
var command = new CreatePlayListCommand(user.Id, "My Playlist", "A description", PictureIntentId: null); private CreatePlayListCommandHandler CreateHandler() => new(
eventBus,
Database,
new UploadIntentValidator(Database, storageService),
NoOpLogger<CreatePlayListCommandHandler>(),
TestConfigurations.Storage(),
TestConfigurations.PlayList(),
TestConfigurations.UploadIntent());
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); [Fact]
public async Task Handle_NoPictureIntent_CreatesPlayListWithPresetPictures()
{
var user = TestEntities.User();
await SeedAsync(user);
Assert.False(result.IsError); var command = new CreatePlayListCommand(user.Id, "My Playlist", "A description", PictureIntentId: null);
Assert.Equal("My Playlist", result.Value.Name);
Assert.Equal("PresetSmallPicture.webp", result.Value.SmallImageKeyName);
await eventBus.DidNotReceive().PublishAsync(Arg.Any<Musify.Application.Events.CreatePlayListResourcesEvent>(), Arg.Any<CancellationToken>());
}
[Fact] var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
public async Task Handle_WithValidPictureIntent_PublishesResourcesEvent()
{
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
var user = TestEntities.User(); Assert.False(result.IsError);
var intent = TestEntities.UploadIntent(user.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.PlayListPicture, objectName: "cover.webp"); Assert.Equal("My Playlist", result.Value.Name);
await SeedAsync(user, intent); Assert.Equal("PresetSmallPicture.webp", result.Value.SmallImageKeyName);
await eventBus.DidNotReceive().PublishAsync(Arg.Any<Musify.Application.Events.CreatePlayListResourcesEvent>(), Arg.Any<CancellationToken>());
}
var command = new CreatePlayListCommand(user.Id, "My Playlist", null, intent.Id); [Fact]
public async Task Handle_WithValidPictureIntent_PublishesResourcesEvent()
{
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 1024));
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); var user = TestEntities.User();
var intent = TestEntities.UploadIntent(user.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.PlayListPicture, objectName: "cover.webp");
await SeedAsync(user, intent);
Assert.False(result.IsError); var command = new CreatePlayListCommand(user.Id, "My Playlist", null, intent.Id);
var stored = await Database.PlayLists.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("cover.webp", stored.Pictures.OriginalName);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreatePlayListResourcesEvent>(), Arg.Any<CancellationToken>());
}
[Fact] var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
public async Task Handle_UserMissing_ReturnsNotFound()
{
var command = new CreatePlayListCommand(404, "Orphan Playlist", null, null);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); Assert.False(result.IsError);
var stored = await Database.PlayLists.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("cover.webp", stored.Pictures.OriginalName);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreatePlayListResourcesEvent>(), Arg.Any<CancellationToken>());
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); [Fact]
} public async Task Handle_UserMissing_ReturnsNotFound()
{
var command = new CreatePlayListCommand(404, "Orphan Playlist", null, null);
[Fact] var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
public async Task Handle_PictureIntentNotFound_ReturnsError()
{
var user = TestEntities.User();
await SeedAsync(user);
var command = new CreatePlayListCommand(user.Id, "My Playlist", null, Guid.NewGuid()); Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); [Fact]
public async Task Handle_PictureIntentNotFound_ReturnsError()
{
var user = TestEntities.User();
await SeedAsync(user);
Assert.True(result.IsError); var command = new CreatePlayListCommand(user.Id, "My Playlist", null, Guid.NewGuid());
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.True(result.IsError);
}
} }
} }
@@ -6,48 +6,49 @@ using Musify.Domain.ValueObjects;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
namespace Musify.Application.Tests.PlayLists; namespace Musify.Application.Tests.PlayLists
public sealed class DeletePlayListCommandHandlerTests : HandlerTestBase
{ {
private readonly IEventBus eventBus = Substitute.For<IEventBus>(); public sealed class DeletePlayListCommandHandlerTests : HandlerTestBase
private DeletePlayListCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger<DeletePlayListCommandHandler>());
[Fact]
public async Task Handle_Owner_MarksPlayListAsRemoving()
{ {
var owner = TestEntities.User(); private readonly IEventBus eventBus = Substitute.For<IEventBus>();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
var result = await CreateHandler().Handle(new DeletePlayListCommand(owner.Id, playList.Id), TestContext.Current.CancellationToken); private DeletePlayListCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger<DeletePlayListCommandHandler>());
Assert.False(result.IsError); [Fact]
var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken); public async Task Handle_Owner_MarksPlayListAsRemoving()
Assert.NotNull(stored); {
Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus); var owner = TestEntities.User();
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.DeletePlayListEvent>(), Arg.Any<CancellationToken>()); var playList = TestEntities.PlayList(owner.Id);
} await SeedAsync(owner, playList);
[Fact] var result = await CreateHandler().Handle(new DeletePlayListCommand(owner.Id, playList.Id), TestContext.Current.CancellationToken);
public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new DeletePlayListCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); Assert.False(result.IsError);
} var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.DeletePlayListEvent>(), Arg.Any<CancellationToken>());
}
[Fact] [Fact]
public async Task Handle_NotOwner_ReturnsUnauthorized() public async Task Handle_PlayListMissing_ReturnsNotFound()
{ {
var owner = TestEntities.User(1, "owner"); var result = await CreateHandler().Handle(new DeletePlayListCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken);
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, stranger, playList);
var result = await CreateHandler().Handle(new DeletePlayListCommand(stranger.Id, playList.Id), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); [Fact]
public async Task Handle_NotOwner_ReturnsUnauthorized()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, stranger, playList);
var result = await CreateHandler().Handle(new DeletePlayListCommand(stranger.Id, playList.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
}
} }
} }
@@ -4,32 +4,33 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Xunit; using Xunit;
namespace Musify.Application.Tests.PlayLists; namespace Musify.Application.Tests.PlayLists
public sealed class GetPlayListByIdQueryHandlerTests : HandlerTestBase
{ {
private GetPlayListByIdQueryHandler CreateHandler() => new(Database); public sealed class GetPlayListByIdQueryHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_ExistingPlayList_ReturnsItWithCoverTrackIds()
{ {
var owner = TestEntities.User(); private GetPlayListByIdQueryHandler CreateHandler() => new(Database);
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_ExistingPlayList_ReturnsItWithCoverTrackIds()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id), TestContext.Current.CancellationToken);
Assert.Equal(playList.Name, result.Value.Name);
Assert.Equal([track.Id], result.Value.CoverTrackIds);
}
[Fact] Assert.False(result.IsError);
public async Task Handle_PlayListMissing_ReturnsNotFound() Assert.Equal(playList.Name, result.Value.Name);
{ Assert.Equal([track.Id], result.Value.CoverTrackIds);
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken); }
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); [Fact]
public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
} }
} }
@@ -3,35 +3,36 @@ using Musify.Application.PlayLists;
using Musify.Application.Tests.TestSupport; using Musify.Application.Tests.TestSupport;
using Xunit; using Xunit;
namespace Musify.Application.Tests.PlayLists; namespace Musify.Application.Tests.PlayLists
public sealed class GetPlayListCoverQueryHandlerTests : HandlerTestBase
{ {
private GetPlayListCoverQueryHandler CreateHandler() => public sealed class GetPlayListCoverQueryHandlerTests : HandlerTestBase
new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.PlayList());
[Theory]
[InlineData("small", "small.webp")]
[InlineData("large", "large.webp")]
[InlineData("medium", "medium.webp")]
public async Task Handle_ExistingPlayList_ReturnsRequestedSize(string size, string expectedFileName)
{ {
var owner = TestEntities.User(); private GetPlayListCoverQueryHandler CreateHandler() =>
var playList = TestEntities.PlayList(owner.Id); new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.PlayList());
await SeedAsync(owner, playList);
var result = await CreateHandler().Handle(new GetPlayListCoverQuery(playList.Id, size), TestContext.Current.CancellationToken); [Theory]
[InlineData("small", "small.webp")]
[InlineData("large", "large.webp")]
[InlineData("medium", "medium.webp")]
public async Task Handle_ExistingPlayList_ReturnsRequestedSize(string size, string expectedFileName)
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetPlayListCoverQuery(playList.Id, size), TestContext.Current.CancellationToken);
Assert.Equal("covers-bucket", result.Value.Bucket);
Assert.EndsWith(expectedFileName, result.Value.Key);
}
[Fact] Assert.False(result.IsError);
public async Task Handle_PlayListMissing_ReturnsNotFound() Assert.Equal("covers-bucket", result.Value.Bucket);
{ Assert.EndsWith(expectedFileName, result.Value.Key);
var result = await CreateHandler().Handle(new GetPlayListCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken); }
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); [Fact]
public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetPlayListCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
} }
} }
@@ -4,35 +4,36 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Xunit; using Xunit;
namespace Musify.Application.Tests.PlayLists; namespace Musify.Application.Tests.PlayLists
public sealed class GetPlayListTracksQueryHandlerTests : HandlerTestBase
{ {
private GetPlayListTracksQueryHandler CreateHandler() => new(Database); public sealed class GetPlayListTracksQueryHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_TracksOrderedByPosition_ReturnsThemInOrder()
{ {
var owner = TestEntities.User(); private GetPlayListTracksQueryHandler CreateHandler() => new(Database);
var playList = TestEntities.PlayList(owner.Id);
var second = TestEntities.LocalTrack(owner, "Second");
var first = TestEntities.LocalTrack(owner, "First");
await SeedAsync(
owner, playList, second, first,
new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 },
new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 });
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_TracksOrderedByPosition_ReturnsThemInOrder()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var second = TestEntities.LocalTrack(owner, "Second");
var first = TestEntities.LocalTrack(owner, "First");
await SeedAsync(
owner, playList, second, first,
new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 },
new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 });
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title));
}
[Fact] Assert.False(result.IsError);
public async Task Handle_PlayListMissing_ReturnsNotFound() Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title));
{ }
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(Guid.NewGuid(), PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); [Fact]
public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetPlayListTracksQuery(Guid.NewGuid(), PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
} }
} }
@@ -3,40 +3,41 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Xunit; using Xunit;
namespace Musify.Application.Tests.PlayLists; namespace Musify.Application.Tests.PlayLists
public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase
{ {
private GetPlayListsByUserIdQueryHandler CreateHandler() => new(Database); public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_ReturnsOnlyActivePlayListsForThatUser()
{ {
var owner = TestEntities.User(1, "owner"); private GetPlayListsByUserIdQueryHandler CreateHandler() => new(Database);
var other = TestEntities.User(2, "other");
await SeedAsync(
owner, other,
TestEntities.PlayList(owner.Id, "Mine"),
TestEntities.PlayList(owner.Id, "Being removed", lifeCycleStatus: LifeCycleStatus.Removing),
TestEntities.PlayList(other.Id, "Theirs"));
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_ReturnsOnlyActivePlayListsForThatUser()
{
var owner = TestEntities.User(1, "owner");
var other = TestEntities.User(2, "other");
await SeedAsync(
owner, other,
TestEntities.PlayList(owner.Id, "Mine"),
TestEntities.PlayList(owner.Id, "Being removed", lifeCycleStatus: LifeCycleStatus.Removing),
TestEntities.PlayList(other.Id, "Theirs"));
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
var playList = Assert.Single(result.Value.Items);
Assert.Equal("Mine", playList.Name);
}
[Fact] Assert.False(result.IsError);
public async Task Handle_NameFilter_ReturnsOnlyMatchingPlayLists() var playList = Assert.Single(result.Value.Items);
{ Assert.Equal("Mine", playList.Name);
var owner = TestEntities.User(); }
await SeedAsync(owner, TestEntities.PlayList(owner.Id, "Road Trip"), TestEntities.PlayList(owner.Id, "Study Focus"));
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: "road", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_NameFilter_ReturnsOnlyMatchingPlayLists()
{
var owner = TestEntities.User();
await SeedAsync(owner, TestEntities.PlayList(owner.Id, "Road Trip"), TestEntities.PlayList(owner.Id, "Study Focus"));
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: "road", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
var playList = Assert.Single(result.Value.Items);
Assert.Equal("Road Trip", playList.Name); Assert.False(result.IsError);
var playList = Assert.Single(result.Value.Items);
Assert.Equal("Road Trip", playList.Name);
}
} }
} }
@@ -2,30 +2,31 @@ using Musify.Application.PlayLists;
using Musify.Application.Tests.TestSupport; using Musify.Application.Tests.TestSupport;
using Xunit; using Xunit;
namespace Musify.Application.Tests.PlayLists; namespace Musify.Application.Tests.PlayLists
public sealed class GetPlayListsQueryHandlerTests : HandlerTestBase
{ {
private GetPlayListsQueryHandler CreateHandler() => new(Database); public sealed class GetPlayListsQueryHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_ReturnsEveryPlayListPaged()
{ {
var owner = TestEntities.User(); private GetPlayListsQueryHandler CreateHandler() => new(Database);
await SeedAsync(owner, TestEntities.PlayList(owner.Id, "One"), TestEntities.PlayList(owner.Id, "Two"));
var result = await CreateHandler().Handle(new GetPlayListsQuery(PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_ReturnsEveryPlayListPaged()
{
var owner = TestEntities.User();
await SeedAsync(owner, TestEntities.PlayList(owner.Id, "One"), TestEntities.PlayList(owner.Id, "Two"));
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetPlayListsQuery(PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.Equal(2, result.Value.TotalItemCount);
}
[Fact] Assert.False(result.IsError);
public async Task Handle_NoPlayLists_ReturnsEmptyPage() Assert.Equal(2, result.Value.TotalItemCount);
{ }
var result = await CreateHandler().Handle(new GetPlayListsQuery(PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.False(result.IsError); [Fact]
Assert.Empty(result.Value.Items); public async Task Handle_NoPlayLists_ReturnsEmptyPage()
{
var result = await CreateHandler().Handle(new GetPlayListsQuery(PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Empty(result.Value.Items);
}
} }
} }
@@ -4,56 +4,57 @@ using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Xunit; using Xunit;
namespace Musify.Application.Tests.PlayLists; namespace Musify.Application.Tests.PlayLists
public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase
{ {
private RemoveTrackFromPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger<RemoveTrackFromPlayListCommandHandler>()); public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_TrackInPlayList_RemovesTheLink()
{ {
var owner = TestEntities.User(); private RemoveTrackFromPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger<RemoveTrackFromPlayListCommandHandler>());
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_TrackInPlayList_RemovesTheLink()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
Assert.False(result.IsError); var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken);
Assert.Empty(Database.PlayListHasTracks);
}
[Fact] Assert.False(result.IsError);
public async Task Handle_PlayListMissing_ReturnsNotFound() Assert.Empty(Database.PlayListHasTracks);
{ }
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); [Fact]
} public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken);
[Fact] Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
public async Task Handle_NotOwner_ReturnsUnauthorized() }
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, stranger, playList);
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_NotOwner_ReturnsUnauthorized()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, stranger, playList);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
}
[Fact] Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
public async Task Handle_TrackNotLinked_ReturnsNotFound() }
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_TrackNotLinked_ReturnsNotFound()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
} }
} }
@@ -8,67 +8,68 @@ using Musify.Application.Tests.TestSupport;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
namespace Musify.Application.Tests.PlayLists; namespace Musify.Application.Tests.PlayLists
public sealed class RequestPlayListPictureUploadCommandHandlerTests : HandlerTestBase
{ {
private readonly IStorageService storageService = Substitute.For<IStorageService>(); public sealed class RequestPlayListPictureUploadCommandHandlerTests : HandlerTestBase
private RequestPlayListPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null)
{ {
storageService private readonly IStorageService storageService = Substitute.For<IStorageService>();
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
.Returns("https://storage.musify.test/presigned-upload");
return new RequestPlayListPictureUploadCommandHandler( private RequestPlayListPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null)
Database, {
new UploadIntentValidator(Database, storageService), storageService
storageService, .GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
NoOpLogger<RequestPlayListPictureUploadCommandHandler>(), .Returns("https://storage.musify.test/presigned-upload");
TestConfigurations.Storage(),
TestConfigurations.PlayList(),
uploadIntentConfig ?? TestConfigurations.UploadIntent());
}
[Fact] return new RequestPlayListPictureUploadCommandHandler(
public async Task Handle_UserExists_IssuesUploadUrlAndCreatesIntent() Database,
{ new UploadIntentValidator(Database, storageService),
var user = TestEntities.User(); storageService,
await SeedAsync(user); NoOpLogger<RequestPlayListPictureUploadCommandHandler>(),
TestConfigurations.Storage(),
TestConfigurations.PlayList(),
uploadIntentConfig ?? TestConfigurations.UploadIntent());
}
var command = new RequestPlayListPictureUploadCommand(user.Id, "webp", "image/webp"); [Fact]
public async Task Handle_UserExists_IssuesUploadUrlAndCreatesIntent()
{
var user = TestEntities.User();
await SeedAsync(user);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); var command = new RequestPlayListPictureUploadCommand(user.Id, "webp", "image/webp");
Assert.False(result.IsError); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.UploadUrl);
Assert.Single(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken));
}
[Fact] Assert.False(result.IsError);
public async Task Handle_UserMissing_ReturnsNotFound() Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.UploadUrl);
{ Assert.Single(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken));
var command = new RequestPlayListPictureUploadCommand(404, "webp", "image/webp"); }
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); [Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var command = new RequestPlayListPictureUploadCommand(404, "webp", "image/webp");
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
}
[Fact] Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
public async Task Handle_UserOverByteQuota_ReturnsValidationErrorAndCreatesNoIntent() }
{
var config = TestConfigurations.UploadIntent();
config.MaxActiveUploadBytesPerUser = 100;
var user = TestEntities.User(); [Fact]
await SeedAsync(user); public async Task Handle_UserOverByteQuota_ReturnsValidationErrorAndCreatesNoIntent()
{
var config = TestConfigurations.UploadIntent();
config.MaxActiveUploadBytesPerUser = 100;
var command = new RequestPlayListPictureUploadCommand(user.Id, "webp", "image/webp", ExpectedSizeBytes: 1_000_000); var user = TestEntities.User();
await SeedAsync(user);
var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken); var command = new RequestPlayListPictureUploadCommand(user.Id, "webp", "image/webp", ExpectedSizeBytes: 1_000_000);
Assert.Equal(ErrorType.Validation, result.FirstError.Type); var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken);
Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken));
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken));
}
} }
} }
@@ -6,75 +6,76 @@ using Musify.Application.Tests.TestSupport;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
namespace Musify.Application.Tests.PlayLists; namespace Musify.Application.Tests.PlayLists
public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase
{ {
private readonly IEventBus eventBus = Substitute.For<IEventBus>(); public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase
private readonly IStorageService storageService = Substitute.For<IStorageService>();
private UpdatePlayListCommandHandler CreateHandler() => new(
eventBus,
Database,
new UploadIntentValidator(Database, storageService),
NoOpLogger<UpdatePlayListCommandHandler>(),
TestConfigurations.Storage(),
TestConfigurations.PlayList(),
TestConfigurations.UploadIntent());
[Fact]
public async Task Handle_Owner_RenamesPlayList()
{ {
var owner = TestEntities.User(); private readonly IEventBus eventBus = Substitute.For<IEventBus>();
var playList = TestEntities.PlayList(owner.Id, "Old Name"); private readonly IStorageService storageService = Substitute.For<IStorageService>();
await SeedAsync(owner, playList);
var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null); private UpdatePlayListCommandHandler CreateHandler() => new(
eventBus,
Database,
new UploadIntentValidator(Database, storageService),
NoOpLogger<UpdatePlayListCommandHandler>(),
TestConfigurations.Storage(),
TestConfigurations.PlayList(),
TestConfigurations.UploadIntent());
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); [Fact]
public async Task Handle_Owner_RenamesPlayList()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, "Old Name");
await SeedAsync(owner, playList);
Assert.False(result.IsError); var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null);
Assert.Equal("New Name", result.Value.Name);
}
[Fact] var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var command = new UpdatePlayListCommand(1, Guid.NewGuid(), "New Name", null, null);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); Assert.False(result.IsError);
Assert.Equal("New Name", result.Value.Name);
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); [Fact]
} public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var command = new UpdatePlayListCommand(1, Guid.NewGuid(), "New Name", null, null);
[Fact] var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
public async Task Handle_NotOwner_ReturnsUnauthorized()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, stranger, playList);
var command = new UpdatePlayListCommand(stranger.Id, playList.Id, "Hijacked", null, null); Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); [Fact]
public async Task Handle_NotOwner_ReturnsUnauthorized()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, stranger, playList);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); var command = new UpdatePlayListCommand(stranger.Id, playList.Id, "Hijacked", null, null);
}
[Fact] var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
public async Task Handle_BlankNameAndDescription_LeavesThemUnchanged()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, "Original Name", "Original description");
await SeedAsync(owner, playList);
var command = new UpdatePlayListCommand(owner.Id, playList.Id, " ", " ", null); Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); [Fact]
public async Task Handle_BlankNameAndDescription_LeavesThemUnchanged()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, "Original Name", "Original description");
await SeedAsync(owner, playList);
Assert.False(result.IsError); var command = new UpdatePlayListCommand(owner.Id, playList.Id, " ", " ", null);
Assert.Equal("Original Name", result.Value.Name);
Assert.Equal("Original description", result.Value.Description); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal("Original Name", result.Value.Name);
Assert.Equal("Original description", result.Value.Description);
}
} }
} }
@@ -5,46 +5,47 @@ using Musify.Application.Tests.TestSupport;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Services; namespace Musify.Application.Tests.Services
public sealed class TrackStreamIssuerTests : HandlerTestBase
{ {
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>(); public sealed class TrackStreamIssuerTests : HandlerTestBase
private TrackStreamIssuer CreateIssuer() => new(
Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway("https://stream.musify.test/"));
[Fact]
public async Task IssueAsync_BuildsManifestUrlAndRecordsListeningHistory()
{ {
ticketService.IssueTicket(7, Arg.Any<string>()).Returns(new StreamTicket("signed-token", 120)); private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
var owner = TestEntities.User(7);
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, track);
var response = await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 7, TestContext.Current.CancellationToken); private TrackStreamIssuer CreateIssuer() => new(
Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway("https://stream.musify.test/"));
Assert.Equal("signed-token", response.Ticket); [Fact]
Assert.Equal(120, response.ExpiresInSeconds); public async Task IssueAsync_BuildsManifestUrlAndRecordsListeningHistory()
Assert.Equal( {
"https://stream.musify.test/media/Tracks/ProcessedAudios/folder-42/audio.m4a", ticketService.IssueTicket(7, Arg.Any<string>()).Returns(new StreamTicket("signed-token", 120));
response.ManifestUrl); var owner = TestEntities.User(7);
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, track);
var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); var response = await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 7, TestContext.Current.CancellationToken);
Assert.Equal(7, history.UserId);
Assert.Equal(track.Id, history.TrackId);
}
[Fact] Assert.Equal("signed-token", response.Ticket);
public async Task IssueAsync_PassesTheProcessedAudioKeyPrefixToTheTicketService() Assert.Equal(120, response.ExpiresInSeconds);
{ Assert.Equal(
ticketService.IssueTicket(Arg.Any<long>(), Arg.Any<string>()).Returns(new StreamTicket("token", 60)); "https://stream.musify.test/media/Tracks/ProcessedAudios/folder-42/audio.m4a",
var owner = TestEntities.User(1); response.ManifestUrl);
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, track);
await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken); var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
Assert.Equal(7, history.UserId);
Assert.Equal(track.Id, history.TrackId);
}
ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/"); [Fact]
public async Task IssueAsync_PassesTheProcessedAudioKeyPrefixToTheTicketService()
{
ticketService.IssueTicket(Arg.Any<long>(), Arg.Any<string>()).Returns(new StreamTicket("token", 60));
var owner = TestEntities.User(1);
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, track);
await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken);
ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/");
}
} }
} }
@@ -6,140 +6,141 @@ using Musify.Domain.ValueObjects;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Services; namespace Musify.Application.Tests.Services
public sealed class UploadIntentValidatorTests : HandlerTestBase
{ {
private readonly IStorageService storageService = Substitute.For<IStorageService>(); public sealed class UploadIntentValidatorTests : HandlerTestBase
private readonly Domain.Entities.User owner = TestEntities.User();
private UploadIntentValidator CreateValidator() => new(Database, storageService);
[Fact]
public async Task ValidateAndLoadAsync_UploadedObjectWithinLimits_ReturnsTheIntent()
{ {
storageService private readonly IStorageService storageService = Substitute.For<IStorageService>();
.HeadObjectAsync("bucket", "temp/object.webp", Arg.Any<CancellationToken>()) private readonly Domain.Entities.User owner = TestEntities.User();
.Returns(new ObjectMetaData("image/webp", 2048));
var intent = TestEntities.UploadIntent(owner.Id, bucket: "bucket", key: "temp/object.webp"); private UploadIntentValidator CreateValidator() => new(Database, storageService);
await SeedAsync(owner, intent);
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); [Fact]
public async Task ValidateAndLoadAsync_UploadedObjectWithinLimits_ReturnsTheIntent()
{
storageService
.HeadObjectAsync("bucket", "temp/object.webp", Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 2048));
Assert.False(result.IsError); var intent = TestEntities.UploadIntent(owner.Id, bucket: "bucket", key: "temp/object.webp");
Assert.Equal(intent.Id, result.Value.Id); await SeedAsync(owner, intent);
}
[Fact] var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken);
public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound()
{
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), Guid.NewGuid(), owner.Id, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); Assert.False(result.IsError);
} Assert.Equal(intent.Id, result.Value.Id);
}
[Fact] [Fact]
public async Task ValidateAndLoadAsync_IntentBelongsToAnotherUser_ReturnsNotFound() public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound()
{ {
var stranger = TestEntities.User(2, "stranger"); var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), Guid.NewGuid(), owner.Id, TestContext.Current.CancellationToken);
var intent = TestEntities.UploadIntent(owner.Id);
await SeedAsync(owner, stranger, intent);
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, stranger.Id, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); [Fact]
} public async Task ValidateAndLoadAsync_IntentBelongsToAnotherUser_ReturnsNotFound()
{
var stranger = TestEntities.User(2, "stranger");
var intent = TestEntities.UploadIntent(owner.Id);
await SeedAsync(owner, stranger, intent);
[Fact] var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, stranger.Id, TestContext.Current.CancellationToken);
public async Task ValidateAndLoadAsync_AlreadyConsumed_ReturnsConflict()
{
var intent = TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed);
await SeedAsync(owner, intent);
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
Assert.Equal(ErrorType.Conflict, result.FirstError.Type); [Fact]
} public async Task ValidateAndLoadAsync_AlreadyConsumed_ReturnsConflict()
{
var intent = TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed);
await SeedAsync(owner, intent);
[Fact] var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken);
public async Task ValidateAndLoadAsync_PastExpiry_ReturnsValidationError()
{
var intent = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-1));
await SeedAsync(owner, intent);
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
}
Assert.Equal(ErrorType.Validation, result.FirstError.Type); [Fact]
} public async Task ValidateAndLoadAsync_PastExpiry_ReturnsValidationError()
{
var intent = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-1));
await SeedAsync(owner, intent);
[Fact] var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken);
public async Task ValidateAndLoadAsync_ObjectNeverUploaded_ReturnsNotFound()
{
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns((ObjectMetaData?)null);
var intent = TestEntities.UploadIntent(owner.Id); Assert.Equal(ErrorType.Validation, result.FirstError.Type);
await SeedAsync(owner, intent); }
var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); [Fact]
public async Task ValidateAndLoadAsync_ObjectNeverUploaded_ReturnsNotFound()
{
storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns((ObjectMetaData?)null);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); var intent = TestEntities.UploadIntent(owner.Id);
} await SeedAsync(owner, intent);
[Fact] var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken);
public async Task ValidateAndLoadAsync_UploadedObjectExceedsMaxSize_ReturnsValidationError()
{
var config = TestConfigurations.UploadIntent();
config.MaxUploadBytes = 1024;
storageService Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>()) }
.Returns(new ObjectMetaData("image/webp", 2048));
var intent = TestEntities.UploadIntent(owner.Id); [Fact]
await SeedAsync(owner, intent); public async Task ValidateAndLoadAsync_UploadedObjectExceedsMaxSize_ReturnsValidationError()
{
var config = TestConfigurations.UploadIntent();
config.MaxUploadBytes = 1024;
var result = await CreateValidator().ValidateAndLoadAsync(config, intent.Id, owner.Id, TestContext.Current.CancellationToken); storageService
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ObjectMetaData("image/webp", 2048));
Assert.Equal(ErrorType.Validation, result.FirstError.Type); var intent = TestEntities.UploadIntent(owner.Id);
} await SeedAsync(owner, intent);
[Fact] var result = await CreateValidator().ValidateAndLoadAsync(config, intent.Id, owner.Id, TestContext.Current.CancellationToken);
public async Task CheckQuotaAsync_WithinLimits_ReturnsSuccess()
{
await SeedAsync(owner);
var result = await CreateValidator().CheckQuotaAsync(TestConfigurations.UploadIntent(), owner.Id, requiredBytes: 1024, requiredIntentCount: 1, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Validation, result.FirstError.Type);
}
Assert.False(result.IsError); [Fact]
} public async Task CheckQuotaAsync_WithinLimits_ReturnsSuccess()
{
await SeedAsync(owner);
[Fact] var result = await CreateValidator().CheckQuotaAsync(TestConfigurations.UploadIntent(), owner.Id, requiredBytes: 1024, requiredIntentCount: 1, TestContext.Current.CancellationToken);
public async Task CheckQuotaAsync_TooManyActiveIntents_ReturnsValidationError()
{
var config = TestConfigurations.UploadIntent();
config.MaxActiveUploadIntentsPerUser = 1;
await SeedAsync(owner, TestEntities.UploadIntent(owner.Id));
var result = await CreateValidator().CheckQuotaAsync(config, owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken); Assert.False(result.IsError);
}
Assert.Equal(ErrorType.Validation, result.FirstError.Type); [Fact]
} public async Task CheckQuotaAsync_TooManyActiveIntents_ReturnsValidationError()
{
var config = TestConfigurations.UploadIntent();
config.MaxActiveUploadIntentsPerUser = 1;
await SeedAsync(owner, TestEntities.UploadIntent(owner.Id));
[Fact] var result = await CreateValidator().CheckQuotaAsync(config, owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken);
public async Task CheckQuotaAsync_ExpiredOrConsumedIntentsDoNotCountTowardsTheLimit()
{
var config = TestConfigurations.UploadIntent();
config.MaxActiveUploadIntentsPerUser = 1;
await SeedAsync(
owner,
TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Expired),
TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed));
var result = await CreateValidator().CheckQuotaAsync(config, owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Validation, result.FirstError.Type);
}
Assert.False(result.IsError); [Fact]
public async Task CheckQuotaAsync_ExpiredOrConsumedIntentsDoNotCountTowardsTheLimit()
{
var config = TestConfigurations.UploadIntent();
config.MaxActiveUploadIntentsPerUser = 1;
await SeedAsync(
owner,
TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Expired),
TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed));
var result = await CreateValidator().CheckQuotaAsync(config, owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
}
} }
} }
@@ -5,99 +5,100 @@ using Musify.Application.Tests.TestSupport;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Services; namespace Musify.Application.Tests.Services
public sealed class YouTubeTrackProvisionerTests : HandlerTestBase
{ {
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>(); public sealed class YouTubeTrackProvisionerTests : HandlerTestBase
private YouTubeTrackProvisioner CreateProvisioner() =>
new(Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
[Fact]
public async Task FindAsync_UnknownVideo_ReturnsNull()
{ {
var track = await CreateProvisioner().FindAsync("missing-video", TestContext.Current.CancellationToken); private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
Assert.Null(track); private YouTubeTrackProvisioner CreateProvisioner() =>
} new(Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
[Fact] [Fact]
public async Task GetOrCreateAsync_KnownVideo_ReturnsExistingTrackWithoutCallingTheService() public async Task FindAsync_UnknownVideo_ReturnsNull()
{ {
var existing = TestEntities.ExternalTrack(externalId: "known-video"); var track = await CreateProvisioner().FindAsync("missing-video", TestContext.Current.CancellationToken);
await SeedAsync(existing);
var result = await CreateProvisioner().GetOrCreateAsync("known-video", TestContext.Current.CancellationToken); Assert.Null(track);
}
Assert.False(result.IsError); [Fact]
Assert.Equal(existing.Id, result.Value.Track.Id); public async Task GetOrCreateAsync_KnownVideo_ReturnsExistingTrackWithoutCallingTheService()
await youTubeMusicService.DidNotReceiveWithAnyArgs().GetSongAsync(default!, TestContext.Current.CancellationToken); {
} var existing = TestEntities.ExternalTrack(externalId: "known-video");
await SeedAsync(existing);
[Fact] var result = await CreateProvisioner().GetOrCreateAsync("known-video", TestContext.Current.CancellationToken);
public async Task GetOrCreateAsync_NewVideo_CreatesTrackAndDeduplicatesArtistsByName()
{
youTubeMusicService.GetSongAsync("new-video", Arg.Any<CancellationToken>()).Returns(
new YouTubeSongResult(
"new-video", "New Song", "New Artist", "Album", 210, "https://img/thumb.jpg", false,
[new YouTubeArtistRef(null, "New Artist"), new YouTubeArtistRef(null, "new artist")]));
var result = await CreateProvisioner().GetOrCreateAsync("new-video", TestContext.Current.CancellationToken); Assert.False(result.IsError);
Assert.Equal(existing.Id, result.Value.Track.Id);
await youTubeMusicService.DidNotReceiveWithAnyArgs().GetSongAsync(default!, TestContext.Current.CancellationToken);
}
Assert.False(result.IsError); [Fact]
Assert.Equal("New Song", result.Value.Track.Title); public async Task GetOrCreateAsync_NewVideo_CreatesTrackAndDeduplicatesArtistsByName()
Assert.Single(await Database.Artists.ToListAsync(TestContext.Current.CancellationToken)); {
Assert.Single(await Database.TrackArtists.ToListAsync(TestContext.Current.CancellationToken)); youTubeMusicService.GetSongAsync("new-video", Arg.Any<CancellationToken>()).Returns(
} new YouTubeSongResult(
"new-video", "New Song", "New Artist", "Album", 210, "https://img/thumb.jpg", false,
[new YouTubeArtistRef(null, "New Artist"), new YouTubeArtistRef(null, "new artist")]));
[Fact] var result = await CreateProvisioner().GetOrCreateAsync("new-video", TestContext.Current.CancellationToken);
public async Task GetOrCreateAsync_ServiceFails_ReturnsError()
{
youTubeMusicService.GetSongAsync("bad-video", Arg.Any<CancellationToken>())
.Returns(ErrorOr.Error.NotFound(description: "Video not found"));
var result = await CreateProvisioner().GetOrCreateAsync("bad-video", TestContext.Current.CancellationToken); Assert.False(result.IsError);
Assert.Equal("New Song", result.Value.Track.Title);
Assert.Single(await Database.Artists.ToListAsync(TestContext.Current.CancellationToken));
Assert.Single(await Database.TrackArtists.ToListAsync(TestContext.Current.CancellationToken));
}
Assert.True(result.IsError); [Fact]
} public async Task GetOrCreateAsync_ServiceFails_ReturnsError()
{
youTubeMusicService.GetSongAsync("bad-video", Arg.Any<CancellationToken>())
.Returns(ErrorOr.Error.NotFound(description: "Video not found"));
[Fact] var result = await CreateProvisioner().GetOrCreateAsync("bad-video", TestContext.Current.CancellationToken);
public async Task MaterializeAlbumAsync_NewAlbum_CreatesAlbumTracksAndLinks()
{
var detail = new YouTubeAlbumDetail(
new YouTubeAlbumResult("album-1", "Album Title", "Album Artist", "https://img/thumb.jpg", 2024, false, false,
[new YouTubeArtistRef(null, "Album Artist")]),
Description: "desc",
TotalDurationSeconds: 400,
Tracks:
[
new YouTubeAlbumTrack("track-1", "Track One", 200, 1, false),
new YouTubeAlbumTrack("track-2", "Track Two", 200, 2, false)
]);
await CreateProvisioner().MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken); Assert.True(result.IsError);
}
Assert.Equal(2, await Database.ExternalTracks.CountAsync(TestContext.Current.CancellationToken)); [Fact]
Assert.Equal(2, await Database.AlbumHasTracks.CountAsync(TestContext.Current.CancellationToken)); public async Task MaterializeAlbumAsync_NewAlbum_CreatesAlbumTracksAndLinks()
Assert.Single(await Database.ExternalAlbums.ToListAsync(TestContext.Current.CancellationToken)); {
} var detail = new YouTubeAlbumDetail(
new YouTubeAlbumResult("album-1", "Album Title", "Album Artist", "https://img/thumb.jpg", 2024, false, false,
[new YouTubeArtistRef(null, "Album Artist")]),
Description: "desc",
TotalDurationSeconds: 400,
Tracks:
[
new YouTubeAlbumTrack("track-1", "Track One", 200, 1, false),
new YouTubeAlbumTrack("track-2", "Track Two", 200, 2, false)
]);
[Fact] await CreateProvisioner().MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken);
public async Task MaterializeAlbumAsync_CalledTwice_DoesNotDuplicateTracksOrLinks()
{
var detail = new YouTubeAlbumDetail(
new YouTubeAlbumResult("album-1", "Album Title", "Album Artist", "https://img/thumb.jpg", 2024, false, false, []),
Description: "desc",
TotalDurationSeconds: 200,
Tracks: [new YouTubeAlbumTrack("track-1", "Track One", 200, 1, false)]);
var provisioner = CreateProvisioner(); Assert.Equal(2, await Database.ExternalTracks.CountAsync(TestContext.Current.CancellationToken));
await provisioner.MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken); Assert.Equal(2, await Database.AlbumHasTracks.CountAsync(TestContext.Current.CancellationToken));
await provisioner.MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken); Assert.Single(await Database.ExternalAlbums.ToListAsync(TestContext.Current.CancellationToken));
}
Assert.Single(await Database.ExternalTracks.ToListAsync(TestContext.Current.CancellationToken)); [Fact]
Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken)); public async Task MaterializeAlbumAsync_CalledTwice_DoesNotDuplicateTracksOrLinks()
Assert.Single(await Database.ExternalAlbums.ToListAsync(TestContext.Current.CancellationToken)); {
var detail = new YouTubeAlbumDetail(
new YouTubeAlbumResult("album-1", "Album Title", "Album Artist", "https://img/thumb.jpg", 2024, false, false, []),
Description: "desc",
TotalDurationSeconds: 200,
Tracks: [new YouTubeAlbumTrack("track-1", "Track One", 200, 1, false)]);
var provisioner = CreateProvisioner();
await provisioner.MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken);
await provisioner.MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken);
Assert.Single(await Database.ExternalTracks.ToListAsync(TestContext.Current.CancellationToken));
Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken));
Assert.Single(await Database.ExternalAlbums.ToListAsync(TestContext.Current.CancellationToken));
}
} }
} }
@@ -1,40 +1,41 @@
using Musify.Application.Shared; using Musify.Application.Shared;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Shared; namespace Musify.Application.Tests.Shared
public sealed class StorageKeyTests
{ {
[Fact] public sealed class StorageKeyTests
public void Combine_MultipleSegments_JoinsThemWithForwardSlashes()
{ {
var key = StorageKey.Combine("Tracks", "SmallPictures", "cover.webp"); [Fact]
public void Combine_MultipleSegments_JoinsThemWithForwardSlashes()
{
var key = StorageKey.Combine("Tracks", "SmallPictures", "cover.webp");
Assert.Equal("Tracks/SmallPictures/cover.webp", key); Assert.Equal("Tracks/SmallPictures/cover.webp", key);
} }
[Fact] [Fact]
public void Combine_SegmentsWithSurroundingSlashesAndWhitespace_TrimsThem() public void Combine_SegmentsWithSurroundingSlashesAndWhitespace_TrimsThem()
{ {
var key = StorageKey.Combine(" Tracks/ ", "/SmallPictures/", "cover.webp"); var key = StorageKey.Combine(" Tracks/ ", "/SmallPictures/", "cover.webp");
Assert.Equal("Tracks/SmallPictures/cover.webp", key); Assert.Equal("Tracks/SmallPictures/cover.webp", key);
} }
[Theory] [Theory]
[InlineData(null)] [InlineData(null)]
[InlineData("")] [InlineData("")]
[InlineData(" ")] [InlineData(" ")]
public void Combine_BlankSegment_IsSkipped(string? blank) public void Combine_BlankSegment_IsSkipped(string? blank)
{ {
var key = StorageKey.Combine("Tracks", blank!, "cover.webp"); var key = StorageKey.Combine("Tracks", blank!, "cover.webp");
Assert.Equal("Tracks/cover.webp", key); Assert.Equal("Tracks/cover.webp", key);
} }
[Fact] [Fact]
public void Combine_NoSegments_ReturnsEmptyString() public void Combine_NoSegments_ReturnsEmptyString()
{ {
Assert.Equal(string.Empty, StorageKey.Combine()); Assert.Equal(string.Empty, StorageKey.Combine());
}
} }
} }
@@ -4,49 +4,50 @@ using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Shared; namespace Musify.Application.Tests.Shared
public sealed class TrackApplicationResponseTests
{ {
[Fact] public sealed class TrackApplicationResponseTests
public void FromEntity_LocalTrack_UsesOwnerNameAsArtist()
{ {
var owner = TestEntities.User(1, "The Owner"); [Fact]
var track = TestEntities.LocalTrack(owner); public void FromEntity_LocalTrack_UsesOwnerNameAsArtist()
{
var owner = TestEntities.User(1, "The Owner");
var track = TestEntities.LocalTrack(owner);
var response = TrackApplicationResponse.FromEntity(track, listensCount: 3); var response = TrackApplicationResponse.FromEntity(track, listensCount: 3);
Assert.Equal("The Owner", response.Artist); Assert.Equal("The Owner", response.Artist);
Assert.Equal(TrackSource.Local, response.Source); Assert.Equal(TrackSource.Local, response.Source);
Assert.Equal(owner.Id, response.OwnerUserId); Assert.Equal(owner.Id, response.OwnerUserId);
Assert.Equal(3, response.ListensCount); Assert.Equal(3, response.ListensCount);
} }
[Fact] [Fact]
public void FromEntity_ExternalTrackWithArtists_JoinsThemInPositionOrder() public void FromEntity_ExternalTrackWithArtists_JoinsThemInPositionOrder()
{ {
var track = TestEntities.ExternalTrack(); var track = TestEntities.ExternalTrack();
var second = new Artist { Name = "Second Artist", NormalizedName = "SECOND ARTIST", ExternalId = "second" }; var second = new Artist { Name = "Second Artist", NormalizedName = "SECOND ARTIST", ExternalId = "second" };
var first = new Artist { Name = "First Artist", NormalizedName = "FIRST ARTIST", ExternalId = "first" }; var first = new Artist { Name = "First Artist", NormalizedName = "FIRST ARTIST", ExternalId = "first" };
track.TrackArtists = track.TrackArtists =
[ [
new TrackArtist { TrackId = track.Id, ArtistId = second.Id, Position = 1, Artist = second, Track = track }, new TrackArtist { TrackId = track.Id, ArtistId = second.Id, Position = 1, Artist = second, Track = track },
new TrackArtist { TrackId = track.Id, ArtistId = first.Id, Position = 0, Artist = first, Track = track } new TrackArtist { TrackId = track.Id, ArtistId = first.Id, Position = 0, Artist = first, Track = track }
]; ];
var response = TrackApplicationResponse.FromEntity(track, listensCount: 0); var response = TrackApplicationResponse.FromEntity(track, listensCount: 0);
Assert.Equal("First Artist, Second Artist", response.Artist); Assert.Equal("First Artist, Second Artist", response.Artist);
Assert.Equal(track.ExternalId, response.ExternalId); Assert.Equal(track.ExternalId, response.ExternalId);
} }
[Fact] [Fact]
public void FromEntity_ExternalTrackWithNoArtists_HasNullArtist() public void FromEntity_ExternalTrackWithNoArtists_HasNullArtist()
{ {
var track = TestEntities.ExternalTrack(); var track = TestEntities.ExternalTrack();
var response = TrackApplicationResponse.FromEntity(track, listensCount: 0); var response = TrackApplicationResponse.FromEntity(track, listensCount: 0);
Assert.Null(response.Artist); Assert.Null(response.Artist);
}
} }
} }
@@ -2,32 +2,33 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Xunit; using Xunit;
namespace Musify.Application.Tests.TestSupport; namespace Musify.Application.Tests.TestSupport
/// <summary>
/// Base class for handler tests: gives every test method its own empty, isolated
/// <see cref="TestDatabase"/> (xUnit creates a new instance of the test class per test method,
/// so there is no state to reset between tests) and a no-op logger factory for handlers that
/// take an <see cref="ILogger{TCategoryName}"/>.
/// </summary>
public abstract class HandlerTestBase : IAsyncLifetime
{ {
private TestDatabase database = null!; /// <summary>
/// Base class for handler tests: gives every test method its own empty, isolated
protected TestDatabase Database => database; /// <see cref="TestDatabase"/> (xUnit creates a new instance of the test class per test method,
/// so there is no state to reset between tests) and a no-op logger factory for handlers that
public async ValueTask InitializeAsync() => database = await TestDatabase.CreateAsync(); /// take an <see cref="ILogger{TCategoryName}"/>.
/// </summary>
public ValueTask DisposeAsync() => database.DisposeAsync(); public abstract class HandlerTestBase : IAsyncLifetime
protected static ILogger<T> NoOpLogger<T>() => NullLogger<T>.Instance;
/// <summary>Adds the entities and persists them, so handlers under test see committed data.</summary>
protected async Task SeedAsync(params object[] entities)
{ {
foreach (var entity in entities) private TestDatabase database = null!;
Database.Add(entity);
await Database.SaveChangesAsync(CancellationToken.None); protected TestDatabase Database => database;
public async ValueTask InitializeAsync() => database = await TestDatabase.CreateAsync();
public ValueTask DisposeAsync() => database.DisposeAsync();
protected static ILogger<T> NoOpLogger<T>() => NullLogger<T>.Instance;
/// <summary>Adds the entities and persists them, so handlers under test see committed data.</summary>
protected async Task SeedAsync(params object[] entities)
{
foreach (var entity in entities)
Database.Add(entity);
await Database.SaveChangesAsync(CancellationToken.None);
}
} }
} }
@@ -1,24 +1,25 @@
using Musify.Application.Configuration; using Musify.Application.Configuration;
namespace Musify.Application.Tests.TestSupport; namespace Musify.Application.Tests.TestSupport
/// <summary>
/// Configuration POCOs with every <c>required</c> member filled and the rest left at their
/// production defaults, so handler tests don't have to know which fields matter for them.
/// </summary>
public static class TestConfigurations
{ {
public static ApplicationStorageConfiguration Storage(string bucket = "test-bucket") => /// <summary>
new() { Bucket = bucket }; /// Configuration POCOs with every <c>required</c> member filled and the rest left at their
/// production defaults, so handler tests don't have to know which fields matter for them.
/// </summary>
public static class TestConfigurations
{
public static ApplicationStorageConfiguration Storage(string bucket = "test-bucket") =>
new() { Bucket = bucket };
public static TrackConfiguration Track() => new(); public static TrackConfiguration Track() => new();
public static PlayListConfiguration PlayList() => new(); public static PlayListConfiguration PlayList() => new();
public static MixConfiguration Mix() => new(); public static MixConfiguration Mix() => new();
public static UploadIntentConfiguration UploadIntent() => new(); public static UploadIntentConfiguration UploadIntent() => new();
public static StreamGatewayConfiguration StreamGateway(string publicBaseUrl = "https://stream.musify.test") => public static StreamGatewayConfiguration StreamGateway(string publicBaseUrl = "https://stream.musify.test") =>
new() { PublicBaseUrl = publicBaseUrl }; new() { PublicBaseUrl = publicBaseUrl };
}
} }
@@ -3,209 +3,210 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Domain.Entities; using Musify.Domain.Entities;
namespace Musify.Application.Tests.TestSupport; namespace Musify.Application.Tests.TestSupport
/// <summary>
/// An <see cref="IDatabase"/> backed by a private, in-memory SQLite database.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="IDatabase"/> exposes raw <see cref="DbSet{TEntity}"/> properties, so handlers
/// compose real LINQ queries (<c>Where</c>, <c>Select</c>, <c>Include</c>, ...) against it.
/// Substituting <see cref="IDatabase"/> with a mock cannot express that — there is no
/// <see cref="IQueryable{T}"/> provider behind a mock. SQLite's in-memory mode gives us a real
/// relational engine instead: fast, no external dependency, and — unlike the EF Core InMemory
/// provider — it actually supports transactions, which two handlers rely on.
/// </para>
/// <para>
/// The model mirrors the parts of <c>Musify.Infrastructure.Persistence.Database</c> that affect
/// query shape (owned types, table-per-type inheritance, unique indexes). MassTransit outbox/saga
/// state and Postgres-specific configuration are left out — the Application layer never touches
/// them.
/// </para>
/// </remarks>
public sealed class TestDatabase : DbContext, IDatabase
{ {
private readonly SqliteConnection connection;
private TestDatabase(SqliteConnection connection, DbContextOptions<TestDatabase> options)
: base(options)
{
this.connection = connection;
}
/// <summary> /// <summary>
/// Creates a fresh, empty database with its own private connection. Call <see cref="Seed"/> /// An <see cref="IDatabase"/> backed by a private, in-memory SQLite database.
/// (via <see cref="Seeding.EntitySeedExtensions"/>) to populate it, then <see cref="DisposeAsync"/>
/// when the test is done.
/// </summary> /// </summary>
public static async Task<TestDatabase> CreateAsync() /// <remarks>
/// <para>
/// <see cref="IDatabase"/> exposes raw <see cref="DbSet{TEntity}"/> properties, so handlers
/// compose real LINQ queries (<c>Where</c>, <c>Select</c>, <c>Include</c>, ...) against it.
/// Substituting <see cref="IDatabase"/> with a mock cannot express that — there is no
/// <see cref="IQueryable{T}"/> provider behind a mock. SQLite's in-memory mode gives us a real
/// relational engine instead: fast, no external dependency, and — unlike the EF Core InMemory
/// provider — it actually supports transactions, which two handlers rely on.
/// </para>
/// <para>
/// The model mirrors the parts of <c>Musify.Infrastructure.Persistence.Database</c> that affect
/// query shape (owned types, table-per-type inheritance, unique indexes). MassTransit outbox/saga
/// state and Postgres-specific configuration are left out — the Application layer never touches
/// them.
/// </para>
/// </remarks>
public sealed class TestDatabase : DbContext, IDatabase
{ {
var connection = new SqliteConnection("Filename=:memory:"); private readonly SqliteConnection connection;
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<TestDatabase>() private TestDatabase(SqliteConnection connection, DbContextOptions<TestDatabase> options)
.UseSqlite(connection) : base(options)
.Options; {
this.connection = connection;
}
var database = new TestDatabase(connection, options); /// <summary>
await database.Database.EnsureCreatedAsync(); /// Creates a fresh, empty database with its own private connection. Call <see cref="Seed"/>
return database; /// (via <see cref="Seeding.EntitySeedExtensions"/>) to populate it, then <see cref="DisposeAsync"/>
} /// when the test is done.
/// </summary>
public static async Task<TestDatabase> CreateAsync()
{
var connection = new SqliteConnection("Filename=:memory:");
await connection.OpenAsync();
protected override void OnModelCreating(ModelBuilder modelBuilder) var options = new DbContextOptionsBuilder<TestDatabase>()
{ .UseSqlite(connection)
base.OnModelCreating(modelBuilder); .Options;
modelBuilder.Entity<Track>().OwnsOne(track => track.Pictures); var database = new TestDatabase(connection, options);
modelBuilder.Entity<Track>().Navigation(track => track.Pictures).IsRequired(); await database.Database.EnsureCreatedAsync();
return database;
}
modelBuilder.Entity<Track>().OwnsOne(track => track.Audio); protected override void OnModelCreating(ModelBuilder modelBuilder)
modelBuilder.Entity<Track>().Navigation(track => track.Audio).IsRequired(); {
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<LocalTrack>().ToTable("LocalTracks"); modelBuilder.Entity<Track>().OwnsOne(track => track.Pictures);
modelBuilder.Entity<Track>().Navigation(track => track.Pictures).IsRequired();
modelBuilder.Entity<LocalTrack>() modelBuilder.Entity<Track>().OwnsOne(track => track.Audio);
.HasOne(track => track.Owner) modelBuilder.Entity<Track>().Navigation(track => track.Audio).IsRequired();
.WithMany()
.HasForeignKey(track => track.OwnerUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<ExternalTrack>().ToTable("ExternalTracks"); modelBuilder.Entity<LocalTrack>().ToTable("LocalTracks");
modelBuilder.Entity<ExternalTrack>() modelBuilder.Entity<LocalTrack>()
.HasIndex(track => new { track.Source, track.ExternalId }) .HasOne(track => track.Owner)
.IsUnique(); .WithMany()
.HasForeignKey(track => track.OwnerUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<TrackArtist>() modelBuilder.Entity<ExternalTrack>().ToTable("ExternalTracks");
.HasKey(trackArtist => new { trackArtist.TrackId, trackArtist.ArtistId });
modelBuilder.Entity<TrackArtist>() modelBuilder.Entity<ExternalTrack>()
.HasOne(trackArtist => trackArtist.Track) .HasIndex(track => new { track.Source, track.ExternalId })
.WithMany(track => track.TrackArtists) .IsUnique();
.HasForeignKey(trackArtist => trackArtist.TrackId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<TrackArtist>() modelBuilder.Entity<TrackArtist>()
.HasOne(trackArtist => trackArtist.Artist) .HasKey(trackArtist => new { trackArtist.TrackId, trackArtist.ArtistId });
.WithMany(artist => artist.TrackArtists)
.HasForeignKey(trackArtist => trackArtist.ArtistId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Artist>() modelBuilder.Entity<TrackArtist>()
.HasIndex(artist => artist.ExternalId) .HasOne(trackArtist => trackArtist.Track)
.IsUnique(); .WithMany(track => track.TrackArtists)
.HasForeignKey(trackArtist => trackArtist.TrackId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<PlayList>().OwnsOne(playList => playList.Pictures); modelBuilder.Entity<TrackArtist>()
modelBuilder.Entity<PlayList>().Navigation(playList => playList.Pictures).IsRequired(); .HasOne(trackArtist => trackArtist.Artist)
.WithMany(artist => artist.TrackArtists)
.HasForeignKey(trackArtist => trackArtist.ArtistId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Album>().OwnsOne(album => album.Pictures); modelBuilder.Entity<Artist>()
modelBuilder.Entity<Album>().Navigation(album => album.Pictures).IsRequired(false); .HasIndex(artist => artist.ExternalId)
.IsUnique();
modelBuilder.Entity<UserAlbum>().ToTable("UserAlbums"); modelBuilder.Entity<PlayList>().OwnsOne(playList => playList.Pictures);
modelBuilder.Entity<PlayList>().Navigation(playList => playList.Pictures).IsRequired();
modelBuilder.Entity<UserAlbum>() modelBuilder.Entity<Album>().OwnsOne(album => album.Pictures);
.HasOne(album => album.Owner) modelBuilder.Entity<Album>().Navigation(album => album.Pictures).IsRequired(false);
.WithMany()
.HasForeignKey(album => album.OwnerUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<ExternalAlbum>().ToTable("ExternalAlbums"); modelBuilder.Entity<UserAlbum>().ToTable("UserAlbums");
modelBuilder.Entity<ExternalAlbum>() modelBuilder.Entity<UserAlbum>()
.HasIndex(album => new { album.Source, album.ExternalId }) .HasOne(album => album.Owner)
.IsUnique(); .WithMany()
.HasForeignKey(album => album.OwnerUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<AlbumHasTrack>() modelBuilder.Entity<ExternalAlbum>().ToTable("ExternalAlbums");
.HasOne(albumTrack => albumTrack.Album)
.WithMany(album => album.AlbumTracks)
.HasForeignKey(albumTrack => albumTrack.AlbumId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<AlbumHasTrack>() modelBuilder.Entity<ExternalAlbum>()
.HasOne(albumTrack => albumTrack.Track) .HasIndex(album => new { album.Source, album.ExternalId })
.WithMany(track => track.AlbumTracks) .IsUnique();
.HasForeignKey(albumTrack => albumTrack.TrackId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<AlbumHasTrack>() modelBuilder.Entity<AlbumHasTrack>()
.HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId }) .HasOne(albumTrack => albumTrack.Album)
.IsUnique(); .WithMany(album => album.AlbumTracks)
.HasForeignKey(albumTrack => albumTrack.AlbumId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Mix>() modelBuilder.Entity<AlbumHasTrack>()
.HasIndex(mix => new { mix.UserId, mix.Position }); .HasOne(albumTrack => albumTrack.Track)
.WithMany(track => track.AlbumTracks)
.HasForeignKey(albumTrack => albumTrack.TrackId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MixItem>() modelBuilder.Entity<AlbumHasTrack>()
.HasOne(item => item.Mix) .HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId })
.WithMany(mix => mix.Items) .IsUnique();
.HasForeignKey(item => item.MixId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MixItem>() modelBuilder.Entity<Mix>()
.HasOne(item => item.Track) .HasIndex(mix => new { mix.UserId, mix.Position });
.WithMany()
.HasForeignKey(item => item.TrackId)
.IsRequired(false)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<MixItem>() modelBuilder.Entity<MixItem>()
.HasIndex(item => new { item.MixId, item.Position }); .HasOne(item => item.Mix)
} .WithMany(mix => mix.Items)
.HasForeignKey(item => item.MixId)
.OnDelete(DeleteBehavior.Cascade);
public DbSet<User> Users => Set<User>(); modelBuilder.Entity<MixItem>()
.HasOne(item => item.Track)
.WithMany()
.HasForeignKey(item => item.TrackId)
.IsRequired(false)
.OnDelete(DeleteBehavior.Cascade);
public DbSet<PlayList> PlayLists => Set<PlayList>(); modelBuilder.Entity<MixItem>()
.HasIndex(item => new { item.MixId, item.Position });
}
public DbSet<Album> Albums => Set<Album>(); public DbSet<User> Users => Set<User>();
public DbSet<UserAlbum> UserAlbums => Set<UserAlbum>(); public DbSet<PlayList> PlayLists => Set<PlayList>();
public DbSet<ExternalAlbum> ExternalAlbums => Set<ExternalAlbum>(); public DbSet<Album> Albums => Set<Album>();
public DbSet<AlbumHasTrack> AlbumHasTracks => Set<AlbumHasTrack>(); public DbSet<UserAlbum> UserAlbums => Set<UserAlbum>();
public DbSet<Track> Tracks => Set<Track>(); public DbSet<ExternalAlbum> ExternalAlbums => Set<ExternalAlbum>();
public DbSet<LocalTrack> LocalTracks => Set<LocalTrack>(); public DbSet<AlbumHasTrack> AlbumHasTracks => Set<AlbumHasTrack>();
public DbSet<ExternalTrack> ExternalTracks => Set<ExternalTrack>(); public DbSet<Track> Tracks => Set<Track>();
public DbSet<Artist> Artists => Set<Artist>(); public DbSet<LocalTrack> LocalTracks => Set<LocalTrack>();
public DbSet<TrackArtist> TrackArtists => Set<TrackArtist>(); public DbSet<ExternalTrack> ExternalTracks => Set<ExternalTrack>();
public DbSet<UserHasTrack> UserHasTracks => Set<UserHasTrack>(); public DbSet<Artist> Artists => Set<Artist>();
public DbSet<PlayListHasTrack> PlayListHasTracks => Set<PlayListHasTrack>(); public DbSet<TrackArtist> TrackArtists => Set<TrackArtist>();
public DbSet<Mix> Mixes => Set<Mix>(); public DbSet<UserHasTrack> UserHasTracks => Set<UserHasTrack>();
public DbSet<MixItem> MixItems => Set<MixItem>(); public DbSet<PlayListHasTrack> PlayListHasTracks => Set<PlayListHasTrack>();
public DbSet<Upload> Uploads => Set<Upload>(); public DbSet<Mix> Mixes => Set<Mix>();
public DbSet<UploadIntent> UploadIntents => Set<UploadIntent>(); public DbSet<MixItem> MixItems => Set<MixItem>();
public DbSet<ListeningHistory> ListeningHistories => Set<ListeningHistory>(); public DbSet<Upload> Uploads => Set<Upload>();
public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) public DbSet<UploadIntent> UploadIntents => Set<UploadIntent>();
{
var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken);
return new SqliteDatabaseTransaction(transaction);
}
public override async ValueTask DisposeAsync() public DbSet<ListeningHistory> ListeningHistories => Set<ListeningHistory>();
{
await base.DisposeAsync();
await connection.DisposeAsync();
}
private sealed class SqliteDatabaseTransaction(Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction inner) public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken)
: IDatabaseTransaction {
{ var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken);
public Task CommitAsync(CancellationToken cancellationToken = default) => return new SqliteDatabaseTransaction(transaction);
inner.CommitAsync(cancellationToken); }
public ValueTask DisposeAsync() => inner.DisposeAsync(); public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
await connection.DisposeAsync();
}
private sealed class SqliteDatabaseTransaction(Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction inner)
: IDatabaseTransaction
{
public Task CommitAsync(CancellationToken cancellationToken = default) =>
inner.CommitAsync(cancellationToken);
public ValueTask DisposeAsync() => inner.DisposeAsync();
}
} }
} }
@@ -1,237 +1,238 @@
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Tests.TestSupport; namespace Musify.Application.Tests.TestSupport
/// <summary>
/// Builds fully-populated domain entities for tests, with sensible defaults for every
/// <c>required</c> member. Every factory always returns a valid instance — never null — so
/// callers never need a null check on what this class hands back.
/// </summary>
public static class TestEntities
{ {
public static User User( /// <summary>
long id = 1, /// Builds fully-populated domain entities for tests, with sensible defaults for every
string name = "test-user", /// <c>required</c> member. Every factory always returns a valid instance — never null — so
string? firstName = "Test", /// callers never need a null check on what this class hands back.
string? secondName = "User", /// </summary>
string? profilePictureUrl = null) => public static class TestEntities
new()
{
Id = id,
Name = name,
NormalizedName = name.ToUpperInvariant(),
FirstName = firstName,
SecondName = secondName,
ProfilePictureUrl = profilePictureUrl
};
public static TrackPictures ProcessedPictures() => new()
{ {
OriginalName = "original.webp", public static User User(
SmallName = "small.webp", long id = 1,
MediumName = "medium.webp", string name = "test-user",
LargeName = "large.webp", string? firstName = "Test",
ProcessingStatus = ProcessingStatus.Completed string? secondName = "User",
}; string? profilePictureUrl = null) =>
new()
{
Id = id,
Name = name,
NormalizedName = name.ToUpperInvariant(),
FirstName = firstName,
SecondName = secondName,
ProfilePictureUrl = profilePictureUrl
};
public static TrackPictures PendingPictures() => new() public static TrackPictures ProcessedPictures() => new()
{
OriginalName = "original.webp",
ProcessingStatus = ProcessingStatus.Pending
};
public static TrackAudio ProcessedAudio(string folderName = "audio-folder") => new()
{
OriginalName = "original.mp3",
FolderName = folderName,
TranscodeStatus = ProcessingStatus.Completed
};
public static TrackAudio PendingAudio() => new()
{
OriginalName = "original.mp3",
TranscodeStatus = ProcessingStatus.Pending
};
public static LocalTrack LocalTrack(
User owner,
string title = "Test Track",
double durationSeconds = 180,
TrackPictures? pictures = null,
TrackAudio? audio = null,
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) =>
new()
{ {
Title = title, OriginalName = "original.webp",
NormalizedTitle = title.ToUpperInvariant(), SmallName = "small.webp",
DurationSeconds = durationSeconds, MediumName = "medium.webp",
LifeCycleStatus = lifeCycleStatus, LargeName = "large.webp",
OwnerUserId = owner.Id, ProcessingStatus = ProcessingStatus.Completed
Owner = owner,
Pictures = pictures ?? ProcessedPictures(),
Audio = audio ?? ProcessedAudio()
}; };
public static ExternalTrack ExternalTrack( public static TrackPictures PendingPictures() => new()
string externalId = "video-id",
string title = "Test External Track",
double durationSeconds = 180,
TrackSource source = TrackSource.YouTube,
TrackPictures? pictures = null,
TrackAudio? audio = null,
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) =>
new()
{ {
Title = title, OriginalName = "original.webp",
NormalizedTitle = title.ToUpperInvariant(), ProcessingStatus = ProcessingStatus.Pending
DurationSeconds = durationSeconds,
LifeCycleStatus = lifeCycleStatus,
Source = source,
ExternalId = externalId,
Pictures = pictures ?? ProcessedPictures(),
Audio = audio ?? ProcessedAudio()
}; };
public static Artist Artist( public static TrackAudio ProcessedAudio(string folderName = "audio-folder") => new()
string name = "Test Artist",
string externalId = "artist-id") =>
new()
{ {
Name = name, OriginalName = "original.mp3",
NormalizedName = name.ToUpperInvariant(), FolderName = folderName,
ExternalId = externalId TranscodeStatus = ProcessingStatus.Completed
}; };
public static PlayListPictures PlayListPictures() => new() public static TrackAudio PendingAudio() => new()
{
OriginalName = "original.webp",
SmallName = "small.webp",
MediumName = "medium.webp",
LargeName = "large.webp"
};
public static PlayList PlayList(
long userId,
string name = "Test Playlist",
string? description = null,
PlayListPictures? pictures = null,
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) =>
new()
{ {
UserId = userId, OriginalName = "original.mp3",
Name = name, TranscodeStatus = ProcessingStatus.Pending
NormalizedName = name.ToUpperInvariant(),
Description = description,
Pictures = pictures ?? PlayListPictures(),
LifeCycleStatus = lifeCycleStatus
}; };
public static AlbumPictures AlbumPictures() => new() public static LocalTrack LocalTrack(
{ User owner,
OriginalName = "original.webp", string title = "Test Track",
SmallName = "small.webp", double durationSeconds = 180,
MediumName = "medium.webp", TrackPictures? pictures = null,
LargeName = "large.webp" TrackAudio? audio = null,
}; LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) =>
new()
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
DurationSeconds = durationSeconds,
LifeCycleStatus = lifeCycleStatus,
OwnerUserId = owner.Id,
Owner = owner,
Pictures = pictures ?? ProcessedPictures(),
Audio = audio ?? ProcessedAudio()
};
public static UserAlbum UserAlbum( public static ExternalTrack ExternalTrack(
long ownerUserId, string externalId = "video-id",
string title = "Test Album", string title = "Test External Track",
string? description = null, double durationSeconds = 180,
int? releaseYear = 2024, TrackSource source = TrackSource.YouTube,
AlbumPictures? pictures = null) => TrackPictures? pictures = null,
new() TrackAudio? audio = null,
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) =>
new()
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
DurationSeconds = durationSeconds,
LifeCycleStatus = lifeCycleStatus,
Source = source,
ExternalId = externalId,
Pictures = pictures ?? ProcessedPictures(),
Audio = audio ?? ProcessedAudio()
};
public static Artist Artist(
string name = "Test Artist",
string externalId = "artist-id") =>
new()
{
Name = name,
NormalizedName = name.ToUpperInvariant(),
ExternalId = externalId
};
public static PlayListPictures PlayListPictures() => new()
{ {
Title = title, OriginalName = "original.webp",
NormalizedTitle = title.ToUpperInvariant(), SmallName = "small.webp",
Description = description, MediumName = "medium.webp",
ReleaseYear = releaseYear, LargeName = "large.webp"
OwnerUserId = ownerUserId,
Pictures = pictures ?? AlbumPictures()
}; };
public static ExternalAlbum ExternalAlbum( public static PlayList PlayList(
string externalId = "album-id", long userId,
string title = "Test External Album", string name = "Test Playlist",
TrackSource source = TrackSource.YouTube, string? description = null,
string? thumbnailUrl = null) => PlayListPictures? pictures = null,
new() LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) =>
new()
{
UserId = userId,
Name = name,
NormalizedName = name.ToUpperInvariant(),
Description = description,
Pictures = pictures ?? PlayListPictures(),
LifeCycleStatus = lifeCycleStatus
};
public static AlbumPictures AlbumPictures() => new()
{ {
Title = title, OriginalName = "original.webp",
NormalizedTitle = title.ToUpperInvariant(), SmallName = "small.webp",
Source = source, MediumName = "medium.webp",
ExternalId = externalId, LargeName = "large.webp"
ThumbnailUrl = thumbnailUrl
}; };
public static UploadIntent UploadIntent( public static UserAlbum UserAlbum(
long userId, long ownerUserId,
UploadIntentPurpose purpose = UploadIntentPurpose.TrackPicture, string title = "Test Album",
string bucket = "test-bucket", string? description = null,
string key = "temp/object.webp", int? releaseYear = 2024,
string objectName = "object.webp", AlbumPictures? pictures = null) =>
string contentType = "image/webp", new()
long? expectedSizeBytes = 1024, {
UploadIntentStatus status = UploadIntentStatus.Issued, Title = title,
DateTime? expiresAt = null) => NormalizedTitle = title.ToUpperInvariant(),
new() Description = description,
{ ReleaseYear = releaseYear,
UserId = userId, OwnerUserId = ownerUserId,
Bucket = bucket, Pictures = pictures ?? AlbumPictures()
Key = key, };
ObjectName = objectName,
ContentType = contentType,
ExpectedSizeBytes = expectedSizeBytes,
Purpose = purpose,
Status = status,
ExpiresAt = expiresAt ?? DateTime.UtcNow.AddMinutes(5)
};
public static Mix Mix( public static ExternalAlbum ExternalAlbum(
long userId, string externalId = "album-id",
string title = "Test Mix", string title = "Test External Album",
string? subtitle = null, TrackSource source = TrackSource.YouTube,
int position = 0) => string? thumbnailUrl = null) =>
new() new()
{ {
UserId = userId, Title = title,
Title = title, NormalizedTitle = title.ToUpperInvariant(),
Subtitle = subtitle, Source = source,
Position = position ExternalId = externalId,
}; ThumbnailUrl = thumbnailUrl
};
public static MixItem MixItem( public static UploadIntent UploadIntent(
Guid mixId, long userId,
int position = 0, UploadIntentPurpose purpose = UploadIntentPurpose.TrackPicture,
MixItemSource source = MixItemSource.Musify, string bucket = "test-bucket",
Guid? trackId = null, string key = "temp/object.webp",
string? videoId = null, string objectName = "object.webp",
string title = "Test Mix Item", string contentType = "image/webp",
string? artist = "Test Artist", long? expectedSizeBytes = 1024,
double durationSeconds = 180, UploadIntentStatus status = UploadIntentStatus.Issued,
bool isExplicit = false) => DateTime? expiresAt = null) =>
new() new()
{ {
MixId = mixId, UserId = userId,
Position = position, Bucket = bucket,
Source = source, Key = key,
TrackId = trackId, ObjectName = objectName,
VideoId = videoId, ContentType = contentType,
Title = title, ExpectedSizeBytes = expectedSizeBytes,
Artist = artist, Purpose = purpose,
DurationSeconds = durationSeconds, Status = status,
IsExplicit = isExplicit ExpiresAt = expiresAt ?? DateTime.UtcNow.AddMinutes(5)
}; };
public static ListeningHistory ListeningHistory( public static Mix Mix(
long userId, long userId,
Guid trackId, string title = "Test Mix",
DateTime? listenedAt = null) => string? subtitle = null,
new() int position = 0) =>
{ new()
UserId = userId, {
TrackId = trackId, UserId = userId,
ListenedAt = listenedAt ?? DateTime.UtcNow Title = title,
}; Subtitle = subtitle,
Position = position
};
public static MixItem MixItem(
Guid mixId,
int position = 0,
MixItemSource source = MixItemSource.Musify,
Guid? trackId = null,
string? videoId = null,
string title = "Test Mix Item",
string? artist = "Test Artist",
double durationSeconds = 180,
bool isExplicit = false) =>
new()
{
MixId = mixId,
Position = position,
Source = source,
TrackId = trackId,
VideoId = videoId,
Title = title,
Artist = artist,
DurationSeconds = durationSeconds,
IsExplicit = isExplicit
};
public static ListeningHistory ListeningHistory(
long userId,
Guid trackId,
DateTime? listenedAt = null) =>
new()
{
UserId = userId,
TrackId = trackId,
ListenedAt = listenedAt ?? DateTime.UtcNow
};
}
} }
@@ -7,92 +7,93 @@ using Musify.Domain.ValueObjects;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Tracks; namespace Musify.Application.Tests.Tracks
public sealed class CreateTrackCommandHandlerTests : HandlerTestBase
{ {
private readonly IEventBus eventBus = Substitute.For<IEventBus>(); public sealed class CreateTrackCommandHandlerTests : HandlerTestBase
private readonly IStorageService storageService = Substitute.For<IStorageService>();
private CreateTrackCommandHandler CreateHandler()
{ {
storageService private readonly IEventBus eventBus = Substitute.For<IEventBus>();
.HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>()) private readonly IStorageService storageService = Substitute.For<IStorageService>();
.Returns(new ObjectMetaData("image/webp", 1024));
return new CreateTrackCommandHandler( private CreateTrackCommandHandler CreateHandler()
Database, {
eventBus, storageService
new UploadIntentValidator(Database, storageService), .HeadObjectAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
NoOpLogger<CreateTrackCommandHandler>(), .Returns(new ObjectMetaData("image/webp", 1024));
TestConfigurations.Storage(),
TestConfigurations.Track(),
TestConfigurations.UploadIntent());
}
private static (Domain.Entities.UploadIntent Picture, Domain.Entities.UploadIntent Audio) SeedIntents(long userId) => return new CreateTrackCommandHandler(
(TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackPicture, objectName: "cover.webp"), Database,
TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackAudio, objectName: "song.mp3")); eventBus,
new UploadIntentValidator(Database, storageService),
NoOpLogger<CreateTrackCommandHandler>(),
TestConfigurations.Storage(),
TestConfigurations.Track(),
TestConfigurations.UploadIntent());
}
[Fact] private static (Domain.Entities.UploadIntent Picture, Domain.Entities.UploadIntent Audio) SeedIntents(long userId) =>
public async Task Handle_ValidIntents_CreatesTrackAndPublishesEvent() (TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackPicture, objectName: "cover.webp"),
{ TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackAudio, objectName: "song.mp3"));
var user = TestEntities.User();
var (picture, audio) = SeedIntents(user.Id);
await SeedAsync(user, picture, audio);
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id); [Fact]
public async Task Handle_ValidIntents_CreatesTrackAndPublishesEvent()
{
var user = TestEntities.User();
var (picture, audio) = SeedIntents(user.Id);
await SeedAsync(user, picture, audio);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id);
Assert.False(result.IsError); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.Equal("My Song", result.Value.Title);
Assert.Equal(user.Id, result.Value.OwnerUserId);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreateTrackResourcesEvent>(), Arg.Any<CancellationToken>());
var stored = await Database.LocalTracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); Assert.False(result.IsError);
Assert.NotNull(stored); Assert.Equal("My Song", result.Value.Title);
Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus); Assert.Equal(user.Id, result.Value.OwnerUserId);
} await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreateTrackResourcesEvent>(), Arg.Any<CancellationToken>());
[Fact] var stored = await Database.LocalTracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
public async Task Handle_UserMissing_ReturnsNotFound() Assert.NotNull(stored);
{ Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus);
var command = new CreateTrackCommand(404, "Orphan Song", Guid.NewGuid(), Guid.NewGuid()); }
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); [Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var command = new CreateTrackCommand(404, "Orphan Song", Guid.NewGuid(), Guid.NewGuid());
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
}
[Fact] Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
public async Task Handle_PictureIntentBelongsToAnotherUser_ReturnsError() }
{
var user = TestEntities.User(1, "user");
var otherUser = TestEntities.User(2, "other");
var (picture, audio) = SeedIntents(otherUser.Id);
await SeedAsync(user, otherUser, picture, audio);
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id); [Fact]
public async Task Handle_PictureIntentBelongsToAnotherUser_ReturnsError()
{
var user = TestEntities.User(1, "user");
var otherUser = TestEntities.User(2, "other");
var (picture, audio) = SeedIntents(otherUser.Id);
await SeedAsync(user, otherUser, picture, audio);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id);
Assert.True(result.IsError); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
await eventBus.DidNotReceive().PublishAsync(Arg.Any<Musify.Application.Events.CreateTrackResourcesEvent>(), Arg.Any<CancellationToken>());
}
[Fact] Assert.True(result.IsError);
public async Task Handle_AudioIntentAlreadyConsumed_ReturnsError() await eventBus.DidNotReceive().PublishAsync(Arg.Any<Musify.Application.Events.CreateTrackResourcesEvent>(), Arg.Any<CancellationToken>());
{ }
var user = TestEntities.User();
var picture = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackPicture, objectName: "cover.webp");
var audio = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackAudio, objectName: "song.mp3", status: UploadIntentStatus.Consumed);
await SeedAsync(user, picture, audio);
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id); [Fact]
public async Task Handle_AudioIntentAlreadyConsumed_ReturnsError()
{
var user = TestEntities.User();
var picture = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackPicture, objectName: "cover.webp");
var audio = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackAudio, objectName: "song.mp3", status: UploadIntentStatus.Consumed);
await SeedAsync(user, picture, audio);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id);
Assert.Equal(ErrorType.Conflict, result.FirstError.Type); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
}
} }
} }
@@ -6,71 +6,72 @@ using Musify.Domain.ValueObjects;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Tracks; namespace Musify.Application.Tests.Tracks
public sealed class DeleteTrackCommandHandlerTests : HandlerTestBase
{ {
private readonly IEventBus eventBus = Substitute.For<IEventBus>(); public sealed class DeleteTrackCommandHandlerTests : HandlerTestBase
private DeleteTrackCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger<DeleteTrackCommandHandler>());
[Fact]
public async Task Handle_Owner_MarksTrackAsRemovingAndPublishesEvent()
{ {
var owner = TestEntities.User(); private readonly IEventBus eventBus = Substitute.For<IEventBus>();
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); private DeleteTrackCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger<DeleteTrackCommandHandler>());
Assert.False(result.IsError); [Fact]
var stored = await Database.Tracks.FindAsync([track.Id], TestContext.Current.CancellationToken); public async Task Handle_Owner_MarksTrackAsRemovingAndPublishesEvent()
Assert.NotNull(stored); {
Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus); var owner = TestEntities.User();
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.DeleteTrackEvent>(), Arg.Any<CancellationToken>()); var track = TestEntities.LocalTrack(owner);
} await SeedAsync(owner, track);
[Fact] var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken);
public async Task Handle_TrackMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new DeleteTrackCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); Assert.False(result.IsError);
} var stored = await Database.Tracks.FindAsync([track.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus);
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.DeleteTrackEvent>(), Arg.Any<CancellationToken>());
}
[Fact] [Fact]
public async Task Handle_NotOwner_ReturnsUnauthorized() public async Task Handle_TrackMissing_ReturnsNotFound()
{ {
var owner = TestEntities.User(1, "owner"); var result = await CreateHandler().Handle(new DeleteTrackCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken);
var stranger = TestEntities.User(2, "stranger");
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, stranger, track);
var result = await CreateHandler().Handle(new DeleteTrackCommand(stranger.Id, track.Id), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); [Fact]
} public async Task Handle_NotOwner_ReturnsUnauthorized()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, stranger, track);
[Fact] var result = await CreateHandler().Handle(new DeleteTrackCommand(stranger.Id, track.Id), TestContext.Current.CancellationToken);
public async Task Handle_ExternalTrack_ReturnsUnauthorized()
{
var externalTrack = TestEntities.ExternalTrack();
await SeedAsync(externalTrack);
var result = await CreateHandler().Handle(new DeleteTrackCommand(1, externalTrack.Id), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
}
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); [Fact]
} public async Task Handle_ExternalTrack_ReturnsUnauthorized()
{
var externalTrack = TestEntities.ExternalTrack();
await SeedAsync(externalTrack);
[Fact] var result = await CreateHandler().Handle(new DeleteTrackCommand(1, externalTrack.Id), TestContext.Current.CancellationToken);
public async Task Handle_AudioStillProcessing_ReturnsConflict()
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner, audio: TestEntities.PendingAudio());
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
}
Assert.Equal(ErrorType.Conflict, result.FirstError.Type); [Fact]
public async Task Handle_AudioStillProcessing_ReturnsConflict()
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner, audio: TestEntities.PendingAudio());
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
}
} }
} }
@@ -2,37 +2,38 @@ using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks; using Musify.Application.Tracks;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Tracks; namespace Musify.Application.Tests.Tracks
public sealed class GetListeningHistoryQueryHandlerTests : HandlerTestBase
{ {
private GetListeningHistoryQueryHandler CreateHandler() => new(Database); public sealed class GetListeningHistoryQueryHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_MultipleListens_ReturnsDistinctTracksMostRecentFirst()
{ {
var owner = TestEntities.User(); private GetListeningHistoryQueryHandler CreateHandler() => new(Database);
var older = TestEntities.LocalTrack(owner, "Older listen");
var newer = TestEntities.LocalTrack(owner, "Newer listen");
await SeedAsync(
owner, older, newer,
TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-10)),
TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-5)),
TestEntities.ListeningHistory(owner.Id, newer.Id, DateTime.UtcNow));
var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_MultipleListens_ReturnsDistinctTracksMostRecentFirst()
{
var owner = TestEntities.User();
var older = TestEntities.LocalTrack(owner, "Older listen");
var newer = TestEntities.LocalTrack(owner, "Newer listen");
await SeedAsync(
owner, older, newer,
TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-10)),
TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-5)),
TestEntities.ListeningHistory(owner.Id, newer.Id, DateTime.UtcNow));
Assert.Equal(["Newer listen", "Older listen"], result.Select(track => track.Title)); var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken);
}
[Fact] Assert.Equal(["Newer listen", "Older listen"], result.Select(track => track.Title));
public async Task Handle_NoHistory_ReturnsEmpty() }
{
var owner = TestEntities.User();
await SeedAsync(owner);
var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_NoHistory_ReturnsEmpty()
{
var owner = TestEntities.User();
await SeedAsync(owner);
Assert.Empty(result); var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken);
Assert.Empty(result);
}
} }
} }
@@ -3,32 +3,33 @@ using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks; using Musify.Application.Tracks;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Tracks; namespace Musify.Application.Tests.Tracks
public sealed class GetTrackByIdQueryHandlerTests : HandlerTestBase
{ {
private GetTrackByIdQueryHandler CreateHandler() => new(Database); public sealed class GetTrackByIdQueryHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_LocalTrack_ReturnsItWithOwnerAsArtist()
{ {
var owner = TestEntities.User(1, "artist-name"); private GetTrackByIdQueryHandler CreateHandler() => new(Database);
var track = TestEntities.LocalTrack(owner, "My Song");
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_LocalTrack_ReturnsItWithOwnerAsArtist()
{
var owner = TestEntities.User(1, "artist-name");
var track = TestEntities.LocalTrack(owner, "My Song");
await SeedAsync(owner, track);
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken);
Assert.Equal("My Song", result.Value.Title);
Assert.Equal(owner.Name, result.Value.Artist);
Assert.Equal(owner.Id, result.Value.OwnerUserId);
}
[Fact] Assert.False(result.IsError);
public async Task Handle_TrackMissing_ReturnsNotFound() Assert.Equal("My Song", result.Value.Title);
{ Assert.Equal(owner.Name, result.Value.Artist);
var result = await CreateHandler().Handle(new GetTrackByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken); Assert.Equal(owner.Id, result.Value.OwnerUserId);
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); [Fact]
public async Task Handle_TrackMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetTrackByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
} }
} }
@@ -3,49 +3,50 @@ using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks; using Musify.Application.Tracks;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Tracks; namespace Musify.Application.Tests.Tracks
public sealed class GetTrackCoverQueryHandlerTests : HandlerTestBase
{ {
private GetTrackCoverQueryHandler CreateHandler() => public sealed class GetTrackCoverQueryHandlerTests : HandlerTestBase
new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.Track());
[Theory]
[InlineData("small", "small.webp")]
[InlineData("medium", "medium.webp")]
[InlineData("large", "large.webp")]
[InlineData("unknown-size-falls-back-to-medium", "medium.webp")]
public async Task Handle_ProcessedTrack_ReturnsRequestedSize(string size, string expectedFileName)
{ {
var owner = TestEntities.User(); private GetTrackCoverQueryHandler CreateHandler() =>
var track = TestEntities.LocalTrack(owner); new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.Track());
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, size), TestContext.Current.CancellationToken); [Theory]
[InlineData("small", "small.webp")]
[InlineData("medium", "medium.webp")]
[InlineData("large", "large.webp")]
[InlineData("unknown-size-falls-back-to-medium", "medium.webp")]
public async Task Handle_ProcessedTrack_ReturnsRequestedSize(string size, string expectedFileName)
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, track);
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, size), TestContext.Current.CancellationToken);
Assert.Equal("covers-bucket", result.Value.Bucket);
Assert.EndsWith(expectedFileName, result.Value.Key);
Assert.Equal("image/webp", result.Value.ContentType);
}
[Fact] Assert.False(result.IsError);
public async Task Handle_TrackMissing_ReturnsNotFound() Assert.Equal("covers-bucket", result.Value.Bucket);
{ Assert.EndsWith(expectedFileName, result.Value.Key);
var result = await CreateHandler().Handle(new GetTrackCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken); Assert.Equal("image/webp", result.Value.ContentType);
}
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); [Fact]
} public async Task Handle_TrackMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetTrackCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken);
[Fact] Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
public async Task Handle_PictureNotProcessedYet_ReturnsNotFound() }
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner, pictures: TestEntities.PendingPictures());
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, "small"), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_PictureNotProcessedYet_ReturnsNotFound()
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner, pictures: TestEntities.PendingPictures());
await SeedAsync(owner, track);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, "small"), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
} }
} }
@@ -7,61 +7,62 @@ using Musify.Application.Tracks;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Tracks; namespace Musify.Application.Tests.Tracks
public sealed class GetTrackStreamQueryHandlerTests : HandlerTestBase
{ {
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>(); public sealed class GetTrackStreamQueryHandlerTests : HandlerTestBase
private GetTrackStreamQueryHandler CreateHandler()
{ {
ticketService.IssueTicket(Arg.Any<long>(), Arg.Any<string>()).Returns(new StreamTicket("ticket-token", 60)); private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
var issuer = new TrackStreamIssuer( private GetTrackStreamQueryHandler CreateHandler()
Database, {
ticketService, ticketService.IssueTicket(Arg.Any<long>(), Arg.Any<string>()).Returns(new StreamTicket("ticket-token", 60));
TestConfigurations.Track(),
TestConfigurations.StreamGateway("https://stream.musify.test"));
return new GetTrackStreamQueryHandler(Database, issuer, NoOpLogger<GetTrackStreamQueryHandler>()); var issuer = new TrackStreamIssuer(
} Database,
ticketService,
TestConfigurations.Track(),
TestConfigurations.StreamGateway("https://stream.musify.test"));
[Fact] return new GetTrackStreamQueryHandler(Database, issuer, NoOpLogger<GetTrackStreamQueryHandler>());
public async Task Handle_ProcessedTrack_IssuesTicketAndRecordsListen() }
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_ProcessedTrack_IssuesTicketAndRecordsListen()
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, track);
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken);
Assert.Equal("ticket-token", result.Value.Ticket);
Assert.Equal(60, result.Value.ExpiresInSeconds);
Assert.Contains("audio-folder", result.Value.ManifestUrl);
var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); Assert.False(result.IsError);
Assert.Equal(owner.Id, history.UserId); Assert.Equal("ticket-token", result.Value.Ticket);
Assert.Equal(track.Id, history.TrackId); Assert.Equal(60, result.Value.ExpiresInSeconds);
} Assert.Contains("audio-folder", result.Value.ManifestUrl);
[Fact] var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
public async Task Handle_TrackMissing_ReturnsNotFound() Assert.Equal(owner.Id, history.UserId);
{ Assert.Equal(track.Id, history.TrackId);
var result = await CreateHandler().Handle(new GetTrackStreamQuery(Guid.NewGuid(), 1), TestContext.Current.CancellationToken); }
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); [Fact]
} public async Task Handle_TrackMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetTrackStreamQuery(Guid.NewGuid(), 1), TestContext.Current.CancellationToken);
[Fact] Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
public async Task Handle_AudioNotProcessedYet_ReturnsConflict() }
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner, audio: TestEntities.PendingAudio());
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_AudioNotProcessedYet_ReturnsConflict()
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner, audio: TestEntities.PendingAudio());
await SeedAsync(owner, track);
Assert.Equal(ErrorType.Conflict, result.FirstError.Type); var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
}
} }
} }
@@ -3,43 +3,44 @@ using Musify.Application.Tracks;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Tracks; namespace Musify.Application.Tests.Tracks
public sealed class GetTracksByUserIdQueryHandlerTests : HandlerTestBase
{ {
private GetTracksByUserIdQueryHandler CreateHandler() => new(Database); public sealed class GetTracksByUserIdQueryHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_ReturnsOnlyTracksLinkedToThatUser()
{ {
var owner = TestEntities.User(1, "owner"); private GetTracksByUserIdQueryHandler CreateHandler() => new(Database);
var other = TestEntities.User(2, "other");
var mine = TestEntities.LocalTrack(owner, "Mine");
var theirs = TestEntities.LocalTrack(other, "Theirs");
await SeedAsync(
owner, other, mine, theirs,
new UserHasTrack { UserId = owner.Id, TrackId = mine.Id },
new UserHasTrack { UserId = other.Id, TrackId = theirs.Id });
var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_ReturnsOnlyTracksLinkedToThatUser()
{
var owner = TestEntities.User(1, "owner");
var other = TestEntities.User(2, "other");
var mine = TestEntities.LocalTrack(owner, "Mine");
var theirs = TestEntities.LocalTrack(other, "Theirs");
await SeedAsync(
owner, other, mine, theirs,
new UserHasTrack { UserId = owner.Id, TrackId = mine.Id },
new UserHasTrack { UserId = other.Id, TrackId = theirs.Id });
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
var track = Assert.Single(result.Value.Items);
Assert.Equal("Mine", track.Title);
}
[Theory] Assert.False(result.IsError);
[InlineData(0, 1)] var track = Assert.Single(result.Value.Items);
[InlineData(-5, 1)] Assert.Equal("Mine", track.Title);
public async Task Handle_NonPositivePageNumber_FallsBackToFirstPage(int requestedPageNumber, int expectedPageNumber) }
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, track, new UserHasTrack { UserId = owner.Id, TrackId = track.Id });
var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: requestedPageNumber, PageSize: 10), TestContext.Current.CancellationToken); [Theory]
[InlineData(0, 1)]
[InlineData(-5, 1)]
public async Task Handle_NonPositivePageNumber_FallsBackToFirstPage(int requestedPageNumber, int expectedPageNumber)
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, track, new UserHasTrack { UserId = owner.Id, TrackId = track.Id });
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: requestedPageNumber, PageSize: 10), TestContext.Current.CancellationToken);
Assert.Equal(expectedPageNumber, result.Value.PageNumber);
Assert.False(result.IsError);
Assert.Equal(expectedPageNumber, result.Value.PageNumber);
}
} }
} }
@@ -2,44 +2,45 @@ using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks; using Musify.Application.Tracks;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Tracks; namespace Musify.Application.Tests.Tracks
public sealed class GetTracksQueryHandlerTests : HandlerTestBase
{ {
private GetTracksQueryHandler CreateHandler() => new(Database); public sealed class GetTracksQueryHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns()
{ {
var owner = TestEntities.User(); private GetTracksQueryHandler CreateHandler() => new(Database);
var trackA = TestEntities.LocalTrack(owner, "Alpha");
var trackB = TestEntities.LocalTrack(owner, "Beta");
await SeedAsync(
owner, trackA, trackB,
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = trackA.Id },
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = trackB.Id });
var result = await CreateHandler().Handle(new GetTracksQuery(Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns()
{
var owner = TestEntities.User();
var trackA = TestEntities.LocalTrack(owner, "Alpha");
var trackB = TestEntities.LocalTrack(owner, "Beta");
await SeedAsync(
owner, trackA, trackB,
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = trackA.Id },
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = trackB.Id });
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetTracksQuery(Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
Assert.Equal(2, result.Value.TotalItemCount);
}
[Fact] Assert.False(result.IsError);
public async Task Handle_NameFilter_ReturnsOnlyMatchingTracks() Assert.Equal(2, result.Value.TotalItemCount);
{ }
var owner = TestEntities.User();
var match = TestEntities.LocalTrack(owner, "Bohemian Rhapsody");
var other = TestEntities.LocalTrack(owner, "Imagine");
await SeedAsync(
owner, match, other,
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = match.Id },
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = other.Id });
var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_NameFilter_ReturnsOnlyMatchingTracks()
{
var owner = TestEntities.User();
var match = TestEntities.LocalTrack(owner, "Bohemian Rhapsody");
var other = TestEntities.LocalTrack(owner, "Imagine");
await SeedAsync(
owner, match, other,
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = match.Id },
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = other.Id });
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
var track = Assert.Single(result.Value.Items);
Assert.Equal("Bohemian Rhapsody", track.Title); Assert.False(result.IsError);
var track = Assert.Single(result.Value.Items);
Assert.Equal("Bohemian Rhapsody", track.Title);
}
} }
} }
@@ -9,72 +9,73 @@ using Musify.Domain.ValueObjects;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Tracks; namespace Musify.Application.Tests.Tracks
public sealed class RequestTrackUploadUrlsCommandHandlerTests : HandlerTestBase
{ {
private readonly IStorageService storageService = Substitute.For<IStorageService>(); public sealed class RequestTrackUploadUrlsCommandHandlerTests : HandlerTestBase
private RequestTrackUploadUrlsCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null)
{ {
storageService private readonly IStorageService storageService = Substitute.For<IStorageService>();
.GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
.Returns("https://storage.musify.test/presigned-upload");
return new RequestTrackUploadUrlsCommandHandler( private RequestTrackUploadUrlsCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null)
Database, {
new UploadIntentValidator(Database, storageService), storageService
storageService, .GetUploadUrlAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<TimeSpan>(), Arg.Any<CancellationToken>())
NoOpLogger<RequestTrackUploadUrlsCommandHandler>(), .Returns("https://storage.musify.test/presigned-upload");
TestConfigurations.Storage(),
TestConfigurations.Track(),
uploadIntentConfig ?? TestConfigurations.UploadIntent());
}
[Fact] return new RequestTrackUploadUrlsCommandHandler(
public async Task Handle_UserExists_IssuesBothUploadUrlsAndCreatesIntents() Database,
{ new UploadIntentValidator(Database, storageService),
var user = TestEntities.User(); storageService,
await SeedAsync(user); NoOpLogger<RequestTrackUploadUrlsCommandHandler>(),
TestConfigurations.Storage(),
TestConfigurations.Track(),
uploadIntentConfig ?? TestConfigurations.UploadIntent());
}
var command = new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg"); [Fact]
public async Task Handle_UserExists_IssuesBothUploadUrlsAndCreatesIntents()
{
var user = TestEntities.User();
await SeedAsync(user);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); var command = new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg");
Assert.False(result.IsError); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.PictureUploadUrl);
Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.AudioUploadUrl);
var intents = await Database.UploadIntents.Where(intent => intent.UserId == user.Id).ToListAsync(TestContext.Current.CancellationToken); Assert.False(result.IsError);
Assert.Equal(2, intents.Count); Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.PictureUploadUrl);
Assert.Contains(intents, intent => intent.Purpose == UploadIntentPurpose.TrackPicture); Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.AudioUploadUrl);
Assert.Contains(intents, intent => intent.Purpose == UploadIntentPurpose.TrackAudio);
}
[Fact] var intents = await Database.UploadIntents.Where(intent => intent.UserId == user.Id).ToListAsync(TestContext.Current.CancellationToken);
public async Task Handle_UserMissing_ReturnsNotFound() Assert.Equal(2, intents.Count);
{ Assert.Contains(intents, intent => intent.Purpose == UploadIntentPurpose.TrackPicture);
var command = new RequestTrackUploadUrlsCommand(404, "webp", "image/webp", "mp3", "audio/mpeg"); Assert.Contains(intents, intent => intent.Purpose == UploadIntentPurpose.TrackAudio);
}
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); [Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var command = new RequestTrackUploadUrlsCommand(404, "webp", "image/webp", "mp3", "audio/mpeg");
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
}
[Fact] Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
public async Task Handle_UserAtActiveIntentLimit_ReturnsValidationErrorAndCreatesNothing() }
{
var config = TestConfigurations.UploadIntent();
config.MaxActiveUploadIntentsPerUser = 1;
var user = TestEntities.User(); [Fact]
await SeedAsync(user, TestEntities.UploadIntent(user.Id)); public async Task Handle_UserAtActiveIntentLimit_ReturnsValidationErrorAndCreatesNothing()
{
var config = TestConfigurations.UploadIntent();
config.MaxActiveUploadIntentsPerUser = 1;
var command = new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg"); var user = TestEntities.User();
await SeedAsync(user, TestEntities.UploadIntent(user.Id));
var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken); var command = new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg");
Assert.Equal(ErrorType.Validation, result.FirstError.Type); var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken);
Assert.Equal(1, await Database.UploadIntents.CountAsync(TestContext.Current.CancellationToken));
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
Assert.Equal(1, await Database.UploadIntents.CountAsync(TestContext.Current.CancellationToken));
}
} }
} }
@@ -3,38 +3,39 @@ using Musify.Application.Tests.TestSupport;
using Musify.Application.Users; using Musify.Application.Users;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Users; namespace Musify.Application.Tests.Users
public sealed class CreateUserCommandHandlerTests : HandlerTestBase
{ {
private CreateUserCommandHandler CreateHandler() => new(Database, NoOpLogger<CreateUserCommandHandler>()); public sealed class CreateUserCommandHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_NewId_CreatesUser()
{ {
var command = new CreateUserCommand(1, "Jane Doe", "Jane", "Doe"); private CreateUserCommandHandler CreateHandler() => new(Database, NoOpLogger<CreateUserCommandHandler>());
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); [Fact]
public async Task Handle_NewId_CreatesUser()
{
var command = new CreateUserCommand(1, "Jane Doe", "Jane", "Doe");
Assert.False(result.IsError); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.Equal("Jane Doe", result.Value.Name);
Assert.Equal(1, result.Value.Id);
var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken); Assert.False(result.IsError);
Assert.NotNull(stored); Assert.Equal("Jane Doe", result.Value.Name);
Assert.Equal("JANE DOE", stored.NormalizedName); Assert.Equal(1, result.Value.Id);
}
[Fact] var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken);
public async Task Handle_IdAlreadyExists_ReturnsConflict() Assert.NotNull(stored);
{ Assert.Equal("JANE DOE", stored.NormalizedName);
var existing = TestEntities.User(1); }
await SeedAsync(existing);
var command = new CreateUserCommand(1, "Someone Else", null, null); [Fact]
public async Task Handle_IdAlreadyExists_ReturnsConflict()
{
var existing = TestEntities.User(1);
await SeedAsync(existing);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); var command = new CreateUserCommand(1, "Someone Else", null, null);
Assert.Equal(ErrorType.Conflict, result.FirstError.Type); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
}
} }
} }
@@ -3,29 +3,30 @@ using Musify.Application.Tests.TestSupport;
using Musify.Application.Users; using Musify.Application.Users;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Users; namespace Musify.Application.Tests.Users
public sealed class GetUserByIdQueryHandlerTests : HandlerTestBase
{ {
private GetUserByIdQueryHandler CreateHandler() => new(Database); public sealed class GetUserByIdQueryHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_ExistingUser_ReturnsIt()
{ {
var user = TestEntities.User(1, "Jane Doe"); private GetUserByIdQueryHandler CreateHandler() => new(Database);
await SeedAsync(user);
var result = await CreateHandler().Handle(new GetUserByIdQuery(1), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_ExistingUser_ReturnsIt()
{
var user = TestEntities.User(1, "Jane Doe");
await SeedAsync(user);
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetUserByIdQuery(1), TestContext.Current.CancellationToken);
Assert.Equal("Jane Doe", result.Value.Name);
}
[Fact] Assert.False(result.IsError);
public async Task Handle_UserMissing_ReturnsNotFound() Assert.Equal("Jane Doe", result.Value.Name);
{ }
var result = await CreateHandler().Handle(new GetUserByIdQuery(404), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); [Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetUserByIdQuery(404), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
} }
} }
@@ -2,32 +2,33 @@ using Musify.Application.Tests.TestSupport;
using Musify.Application.Users; using Musify.Application.Users;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Users; namespace Musify.Application.Tests.Users
public sealed class GetUsersQueryHandlerTests : HandlerTestBase
{ {
private GetUsersQueryHandler CreateHandler() => new(Database); public sealed class GetUsersQueryHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_NoSearch_ReturnsEveryUser()
{ {
await SeedAsync(TestEntities.User(1, "alice"), TestEntities.User(2, "bob")); private GetUsersQueryHandler CreateHandler() => new(Database);
var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_NoSearch_ReturnsEveryUser()
{
await SeedAsync(TestEntities.User(1, "alice"), TestEntities.User(2, "bob"));
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null), TestContext.Current.CancellationToken);
Assert.Equal(2, result.Value.TotalItemCount);
}
[Fact] Assert.False(result.IsError);
public async Task Handle_UsernameSearch_ReturnsOnlyMatches() Assert.Equal(2, result.Value.TotalItemCount);
{ }
await SeedAsync(TestEntities.User(1, "alice-wonderland"), TestEntities.User(2, "bob-builder"));
var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: "alice"), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_UsernameSearch_ReturnsOnlyMatches()
{
await SeedAsync(TestEntities.User(1, "alice-wonderland"), TestEntities.User(2, "bob-builder"));
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: "alice"), TestContext.Current.CancellationToken);
var user = Assert.Single(result.Value.Items);
Assert.Equal(1, user.Id); Assert.False(result.IsError);
var user = Assert.Single(result.Value.Items);
Assert.Equal(1, user.Id);
}
} }
} }
@@ -2,41 +2,42 @@ using Musify.Application.Tests.TestSupport;
using Musify.Application.Users; using Musify.Application.Users;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Users; namespace Musify.Application.Tests.Users
public sealed class SyncUserCommandHandlerTests : HandlerTestBase
{ {
private SyncUserCommandHandler CreateHandler() => new(Database, NoOpLogger<SyncUserCommandHandler>()); public sealed class SyncUserCommandHandlerTests : HandlerTestBase
[Fact]
public async Task Handle_UnknownUser_ProvisionsIt()
{ {
var command = new SyncUserCommand(1, "Jane Doe", "Jane", "Doe", "https://avatar.example/jane.png"); private SyncUserCommandHandler CreateHandler() => new(Database, NoOpLogger<SyncUserCommandHandler>());
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); [Fact]
public async Task Handle_UnknownUser_ProvisionsIt()
{
var command = new SyncUserCommand(1, "Jane Doe", "Jane", "Doe", "https://avatar.example/jane.png");
Assert.False(result.IsError); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("Jane Doe", stored.Name);
Assert.Equal("https://avatar.example/jane.png", stored.ProfilePictureUrl);
}
[Fact] Assert.False(result.IsError);
public async Task Handle_ExistingUser_UpdatesProfileFields() var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken);
{ Assert.NotNull(stored);
var user = TestEntities.User(1, "Old Name", profilePictureUrl: null); Assert.Equal("Jane Doe", stored.Name);
await SeedAsync(user); Assert.Equal("https://avatar.example/jane.png", stored.ProfilePictureUrl);
}
var command = new SyncUserCommand(1, "New Name", "New", "Name", "https://avatar.example/new.png"); [Fact]
public async Task Handle_ExistingUser_UpdatesProfileFields()
{
var user = TestEntities.User(1, "Old Name", profilePictureUrl: null);
await SeedAsync(user);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); var command = new SyncUserCommand(1, "New Name", "New", "Name", "https://avatar.example/new.png");
Assert.False(result.IsError); var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken);
Assert.NotNull(stored); Assert.False(result.IsError);
Assert.Equal("New Name", stored.Name); var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken);
Assert.Equal("NEW NAME", stored.NormalizedName); Assert.NotNull(stored);
Assert.Equal("https://avatar.example/new.png", stored.ProfilePictureUrl); Assert.Equal("New Name", stored.Name);
Assert.Equal("NEW NAME", stored.NormalizedName);
Assert.Equal("https://avatar.example/new.png", stored.ProfilePictureUrl);
}
} }
} }
@@ -7,55 +7,56 @@ using Musify.Application.YouTube;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
namespace Musify.Application.Tests.YouTube; namespace Musify.Application.Tests.YouTube
public sealed class GetYouTubeAlbumQueryHandlerTests : HandlerTestBase
{ {
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>(); public sealed class GetYouTubeAlbumQueryHandlerTests : HandlerTestBase
private GetYouTubeAlbumQueryHandler CreateHandler()
{ {
var provisioner = new YouTubeTrackProvisioner( private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
return new GetYouTubeAlbumQueryHandler(youTubeMusicService, provisioner, NoOpLogger<GetYouTubeAlbumQueryHandler>()); private GetYouTubeAlbumQueryHandler CreateHandler()
} {
var provisioner = new YouTubeTrackProvisioner(
Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
private static YouTubeAlbumDetail BuildDetail() => new( return new GetYouTubeAlbumQueryHandler(youTubeMusicService, provisioner, NoOpLogger<GetYouTubeAlbumQueryHandler>());
new YouTubeAlbumResult("album-1", "Album Title", "Album Artist", "https://img/thumb.jpg", 2024, false, false, []), }
Description: "An album",
TotalDurationSeconds: 600,
Tracks: [new YouTubeAlbumTrack("track-1", "Track One", 200, 1, false)]);
[Fact] private static YouTubeAlbumDetail BuildDetail() => new(
public async Task Handle_EmptyAlbumId_ReturnsValidationError() new YouTubeAlbumResult("album-1", "Album Title", "Album Artist", "https://img/thumb.jpg", 2024, false, false, []),
{ Description: "An album",
var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery(string.Empty), TestContext.Current.CancellationToken); TotalDurationSeconds: 600,
Tracks: [new YouTubeAlbumTrack("track-1", "Track One", 200, 1, false)]);
Assert.Equal(ErrorType.Validation, result.FirstError.Type); [Fact]
} public async Task Handle_EmptyAlbumId_ReturnsValidationError()
{
var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery(string.Empty), TestContext.Current.CancellationToken);
[Fact] Assert.Equal(ErrorType.Validation, result.FirstError.Type);
public async Task Handle_ServiceSucceeds_MaterializesAlbumAndReturnsDetail() }
{
var detail = BuildDetail();
youTubeMusicService.GetAlbumAsync("album-1", Arg.Any<CancellationToken>()).Returns(detail);
var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery("album-1"), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_ServiceSucceeds_MaterializesAlbumAndReturnsDetail()
{
var detail = BuildDetail();
youTubeMusicService.GetAlbumAsync("album-1", Arg.Any<CancellationToken>()).Returns(detail);
Assert.False(result.IsError); var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery("album-1"), TestContext.Current.CancellationToken);
Assert.Same(detail, result.Value);
Assert.NotEmpty(await Database.ExternalAlbums.ToListAsync(TestContext.Current.CancellationToken));
}
[Fact] Assert.False(result.IsError);
public async Task Handle_ServiceFails_ReturnsError() Assert.Same(detail, result.Value);
{ Assert.NotEmpty(await Database.ExternalAlbums.ToListAsync(TestContext.Current.CancellationToken));
youTubeMusicService.GetAlbumAsync("missing", Arg.Any<CancellationToken>()) }
.Returns(Error.NotFound(description: "Album not found"));
var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery("missing"), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_ServiceFails_ReturnsError()
{
youTubeMusicService.GetAlbumAsync("missing", Arg.Any<CancellationToken>())
.Returns(Error.NotFound(description: "Album not found"));
Assert.True(result.IsError); var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery("missing"), TestContext.Current.CancellationToken);
Assert.True(result.IsError);
}
} }
} }
@@ -7,59 +7,60 @@ using Musify.Application.YouTube.Responses;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
namespace Musify.Application.Tests.YouTube; namespace Musify.Application.Tests.YouTube
public sealed class ResolveYouTubeTrackStreamCommandHandlerTests : HandlerTestBase
{ {
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>(); public sealed class ResolveYouTubeTrackStreamCommandHandlerTests : HandlerTestBase
private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
private ResolveYouTubeTrackStreamCommandHandler CreateHandler()
{ {
var provisioner = new YouTubeTrackProvisioner( private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>()); private readonly IStreamTicketService ticketService = Substitute.For<IStreamTicketService>();
var issuer = new TrackStreamIssuer(
Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway());
return new ResolveYouTubeTrackStreamCommandHandler( private ResolveYouTubeTrackStreamCommandHandler CreateHandler()
Database, youTubeMusicService, provisioner, issuer, NoOpLogger<ResolveYouTubeTrackStreamCommandHandler>()); {
} var provisioner = new YouTubeTrackProvisioner(
Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
var issuer = new TrackStreamIssuer(
Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway());
[Fact] return new ResolveYouTubeTrackStreamCommandHandler(
public async Task Handle_AlreadyProcessedTrack_IssuesServerStreamWithoutCallingYouTubeForAudio() Database, youTubeMusicService, provisioner, issuer, NoOpLogger<ResolveYouTubeTrackStreamCommandHandler>());
{ }
ticketService.IssueTicket(Arg.Any<long>(), Arg.Any<string>()).Returns(new StreamTicket("token", 60));
var user = TestEntities.User(1); [Fact]
var track = TestEntities.ExternalTrack(externalId: "abc123"); public async Task Handle_AlreadyProcessedTrack_IssuesServerStreamWithoutCallingYouTubeForAudio()
await SeedAsync(user, track); {
ticketService.IssueTicket(Arg.Any<long>(), Arg.Any<string>()).Returns(new StreamTicket("token", 60));
var result = await CreateHandler().Handle(new ResolveYouTubeTrackStreamCommand("abc123", 1), TestContext.Current.CancellationToken); var user = TestEntities.User(1);
var track = TestEntities.ExternalTrack(externalId: "abc123");
await SeedAsync(user, track);
Assert.False(result.IsError); var result = await CreateHandler().Handle(new ResolveYouTubeTrackStreamCommand("abc123", 1), TestContext.Current.CancellationToken);
Assert.Equal(YouTubeStreamMode.Server, result.Value.Mode);
Assert.Equal("token", result.Value.Ticket);
await youTubeMusicService.DidNotReceiveWithAnyArgs().GetAudioStreamAsync(default!, TestContext.Current.CancellationToken);
}
[Fact] Assert.False(result.IsError);
public async Task Handle_UnknownTrack_ProvisionsItAndRecordsListeningHistory() Assert.Equal(YouTubeStreamMode.Server, result.Value.Mode);
{ Assert.Equal("token", result.Value.Ticket);
youTubeMusicService.GetAudioStreamAsync("new-video", Arg.Any<CancellationToken>()) await youTubeMusicService.DidNotReceiveWithAnyArgs().GetAudioStreamAsync(default!, TestContext.Current.CancellationToken);
.Returns(new YouTubeStreamInfo("https://youtube.example/stream", 300)); }
youTubeMusicService.GetSongAsync("new-video", Arg.Any<CancellationToken>())
.Returns(new YouTubeSongResult("new-video", "Title", "Artist", "Album", 200, "https://img/thumb.jpg", false, []));
youTubeMusicService.ResolveArtworkUrl(Arg.Any<string>()).Returns(callInfo => callInfo.Arg<string>());
await SeedAsync(TestEntities.User(42));
var result = await CreateHandler().Handle(new ResolveYouTubeTrackStreamCommand("new-video", 42), TestContext.Current.CancellationToken); [Fact]
public async Task Handle_UnknownTrack_ProvisionsItAndRecordsListeningHistory()
{
youTubeMusicService.GetAudioStreamAsync("new-video", Arg.Any<CancellationToken>())
.Returns(new YouTubeStreamInfo("https://youtube.example/stream", 300));
youTubeMusicService.GetSongAsync("new-video", Arg.Any<CancellationToken>())
.Returns(new YouTubeSongResult("new-video", "Title", "Artist", "Album", 200, "https://img/thumb.jpg", false, []));
youTubeMusicService.ResolveArtworkUrl(Arg.Any<string>()).Returns(callInfo => callInfo.Arg<string>());
await SeedAsync(TestEntities.User(42));
Assert.False(result.IsError); var result = await CreateHandler().Handle(new ResolveYouTubeTrackStreamCommand("new-video", 42), TestContext.Current.CancellationToken);
Assert.Equal(YouTubeStreamMode.YouTube, result.Value.Mode);
Assert.Equal("https://youtube.example/stream", result.Value.StreamUrl);
Assert.NotNull(result.Value.TrackId);
var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); Assert.False(result.IsError);
Assert.Equal(42, history.UserId); Assert.Equal(YouTubeStreamMode.YouTube, result.Value.Mode);
Assert.Equal("https://youtube.example/stream", result.Value.StreamUrl);
Assert.NotNull(result.Value.TrackId);
var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
Assert.Equal(42, history.UserId);
}
} }
} }
@@ -4,33 +4,34 @@ using Musify.Application.YouTube;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
namespace Musify.Application.Tests.YouTube; namespace Musify.Application.Tests.YouTube
public sealed class SearchYouTubeAlbumsQueryHandlerTests
{ {
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>(); public sealed class SearchYouTubeAlbumsQueryHandlerTests
private SearchYouTubeAlbumsQueryHandler CreateHandler() => new(youTubeMusicService);
[Fact]
public async Task Handle_EmptyQuery_ReturnsValidationErrorWithoutCallingTheService()
{ {
var result = await CreateHandler().Handle(new SearchYouTubeAlbumsQuery(string.Empty, string.Empty), TestContext.Current.CancellationToken); private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
Assert.Equal(ErrorType.Validation, result.FirstError.Type); private SearchYouTubeAlbumsQueryHandler CreateHandler() => new(youTubeMusicService);
await youTubeMusicService.DidNotReceiveWithAnyArgs()
.SearchAlbumsAsync(default!, default!, TestContext.Current.CancellationToken);
}
[Fact] [Fact]
public async Task Handle_NonEmptyQuery_DelegatesToTheService() public async Task Handle_EmptyQuery_ReturnsValidationErrorWithoutCallingTheService()
{ {
var expected = new YouTubeAlbumSearchResult([], "next-token"); var result = await CreateHandler().Handle(new SearchYouTubeAlbumsQuery(string.Empty, string.Empty), TestContext.Current.CancellationToken);
youTubeMusicService.SearchAlbumsAsync("query", "token", Arg.Any<CancellationToken>()).Returns(expected);
var result = await CreateHandler().Handle(new SearchYouTubeAlbumsQuery("query", "token"), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Validation, result.FirstError.Type);
await youTubeMusicService.DidNotReceiveWithAnyArgs()
.SearchAlbumsAsync(default!, default!, TestContext.Current.CancellationToken);
}
Assert.False(result.IsError); [Fact]
Assert.Same(expected, result.Value); public async Task Handle_NonEmptyQuery_DelegatesToTheService()
{
var expected = new YouTubeAlbumSearchResult([], "next-token");
youTubeMusicService.SearchAlbumsAsync("query", "token", Arg.Any<CancellationToken>()).Returns(expected);
var result = await CreateHandler().Handle(new SearchYouTubeAlbumsQuery("query", "token"), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Same(expected, result.Value);
}
} }
} }
@@ -4,33 +4,34 @@ using Musify.Application.YouTube;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
namespace Musify.Application.Tests.YouTube; namespace Musify.Application.Tests.YouTube
public sealed class SearchYouTubeTracksQueryHandlerTests
{ {
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>(); public sealed class SearchYouTubeTracksQueryHandlerTests
private SearchYouTubeTracksQueryHandler CreateHandler() => new(youTubeMusicService);
[Fact]
public async Task Handle_EmptyQuery_ReturnsValidationErrorWithoutCallingTheService()
{ {
var result = await CreateHandler().Handle(new SearchYouTubeTracksQuery(string.Empty, string.Empty), TestContext.Current.CancellationToken); private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
Assert.Equal(ErrorType.Validation, result.FirstError.Type); private SearchYouTubeTracksQueryHandler CreateHandler() => new(youTubeMusicService);
await youTubeMusicService.DidNotReceiveWithAnyArgs()
.SearchSongsAsync(default!, default!, TestContext.Current.CancellationToken);
}
[Fact] [Fact]
public async Task Handle_NonEmptyQuery_DelegatesToTheService() public async Task Handle_EmptyQuery_ReturnsValidationErrorWithoutCallingTheService()
{ {
var expected = new YouTubeSearchResult([], "next-token"); var result = await CreateHandler().Handle(new SearchYouTubeTracksQuery(string.Empty, string.Empty), TestContext.Current.CancellationToken);
youTubeMusicService.SearchSongsAsync("query", "token", Arg.Any<CancellationToken>()).Returns(expected);
var result = await CreateHandler().Handle(new SearchYouTubeTracksQuery("query", "token"), TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Validation, result.FirstError.Type);
await youTubeMusicService.DidNotReceiveWithAnyArgs()
.SearchSongsAsync(default!, default!, TestContext.Current.CancellationToken);
}
Assert.False(result.IsError); [Fact]
Assert.Same(expected, result.Value); public async Task Handle_NonEmptyQuery_DelegatesToTheService()
{
var expected = new YouTubeSearchResult([], "next-token");
youTubeMusicService.SearchSongsAsync("query", "token", Arg.Any<CancellationToken>()).Returns(expected);
var result = await CreateHandler().Handle(new SearchYouTubeTracksQuery("query", "token"), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Same(expected, result.Value);
}
} }
} }
@@ -1,22 +1,23 @@
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Xunit; using Xunit;
namespace Musify.Domain.Tests.ValueObjects; namespace Musify.Domain.Tests.ValueObjects
public sealed class TrackAudioTests
{ {
[Theory] public sealed class TrackAudioTests
[InlineData(ProcessingStatus.Pending, false, false, true)]
[InlineData(ProcessingStatus.Processing, false, false, true)]
[InlineData(ProcessingStatus.Completed, true, false, false)]
[InlineData(ProcessingStatus.Failed, false, true, false)]
public void ComputedFlags_ReflectTranscodeStatus(
ProcessingStatus status, bool expectedProcessed, bool expectedFailed, bool expectedInProgress)
{ {
var audio = new TrackAudio { TranscodeStatus = status }; [Theory]
[InlineData(ProcessingStatus.Pending, false, false, true)]
[InlineData(ProcessingStatus.Processing, false, false, true)]
[InlineData(ProcessingStatus.Completed, true, false, false)]
[InlineData(ProcessingStatus.Failed, false, true, false)]
public void ComputedFlags_ReflectTranscodeStatus(
ProcessingStatus status, bool expectedProcessed, bool expectedFailed, bool expectedInProgress)
{
var audio = new TrackAudio { TranscodeStatus = status };
Assert.Equal(expectedProcessed, audio.IsProcessed); Assert.Equal(expectedProcessed, audio.IsProcessed);
Assert.Equal(expectedFailed, audio.IsFailed); Assert.Equal(expectedFailed, audio.IsFailed);
Assert.Equal(expectedInProgress, audio.IsInProgress); Assert.Equal(expectedInProgress, audio.IsInProgress);
}
} }
} }
@@ -1,19 +1,20 @@
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Xunit; using Xunit;
namespace Musify.Domain.Tests.ValueObjects; namespace Musify.Domain.Tests.ValueObjects
public sealed class TrackPicturesTests
{ {
[Theory] public sealed class TrackPicturesTests
[InlineData(ProcessingStatus.Pending, false)]
[InlineData(ProcessingStatus.Processing, false)]
[InlineData(ProcessingStatus.Completed, true)]
[InlineData(ProcessingStatus.Failed, false)]
public void IsProcessed_ReflectsProcessingStatus(ProcessingStatus status, bool expected)
{ {
var pictures = new TrackPictures { ProcessingStatus = status }; [Theory]
[InlineData(ProcessingStatus.Pending, false)]
[InlineData(ProcessingStatus.Processing, false)]
[InlineData(ProcessingStatus.Completed, true)]
[InlineData(ProcessingStatus.Failed, false)]
public void IsProcessed_ReflectsProcessingStatus(ProcessingStatus status, bool expected)
{
var pictures = new TrackPictures { ProcessingStatus = status };
Assert.Equal(expected, pictures.IsProcessed); Assert.Equal(expected, pictures.IsProcessed);
}
} }
} }
@@ -2,44 +2,45 @@ using Musify.Domain.Entities;
using Musify.Infrastructure.Tests.TestSupport; using Musify.Infrastructure.Tests.TestSupport;
using Xunit; using Xunit;
namespace Musify.Infrastructure.Tests.Persistence; namespace Musify.Infrastructure.Tests.Persistence
[Collection(InfrastructureCollection.Name)]
public sealed class AuditableEntityInterceptorTests(InfrastructureTestFixture fixture)
{ {
[Fact] [Collection(InfrastructureCollection.Name)]
public async Task SavingChanges_NewEntity_SetsCreatedAtAndUpdatedAtToNow() public sealed class AuditableEntityInterceptorTests(InfrastructureTestFixture fixture)
{ {
await using var database = fixture.CreateDatabase(); [Fact]
var before = DateTime.UtcNow; 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" }; var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "audit-user", NormalizedName = "AUDIT-USER" };
await database.Users.AddAsync(user, TestContext.Current.CancellationToken); await database.Users.AddAsync(user, TestContext.Current.CancellationToken);
await database.SaveChangesAsync(TestContext.Current.CancellationToken); await database.SaveChangesAsync(TestContext.Current.CancellationToken);
var after = DateTime.UtcNow; var after = DateTime.UtcNow;
Assert.InRange(user.CreatedAt, before, after); Assert.InRange(user.CreatedAt, before, after);
Assert.InRange(user.UpdatedAt, before, after); Assert.InRange(user.UpdatedAt, before, after);
} }
[Fact] [Fact]
public async Task SavingChanges_ModifiedEntity_BumpsUpdatedAtButKeepsCreatedAt() public async Task SavingChanges_ModifiedEntity_BumpsUpdatedAtButKeepsCreatedAt()
{ {
await using var database = fixture.CreateDatabase(); 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" }; var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "audit-user-2", NormalizedName = "AUDIT-USER-2" };
await database.Users.AddAsync(user, TestContext.Current.CancellationToken); await database.Users.AddAsync(user, TestContext.Current.CancellationToken);
await database.SaveChangesAsync(TestContext.Current.CancellationToken); await database.SaveChangesAsync(TestContext.Current.CancellationToken);
var originalCreatedAt = user.CreatedAt; var originalCreatedAt = user.CreatedAt;
await Task.Delay(TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken); await Task.Delay(TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
user.Name = "audit-user-2-renamed"; user.Name = "audit-user-2-renamed";
await database.SaveChangesAsync(TestContext.Current.CancellationToken); await database.SaveChangesAsync(TestContext.Current.CancellationToken);
Assert.Equal(originalCreatedAt, user.CreatedAt); Assert.Equal(originalCreatedAt, user.CreatedAt);
Assert.True(user.UpdatedAt > originalCreatedAt, Assert.True(user.UpdatedAt > originalCreatedAt,
$"original-created-at={originalCreatedAt:O}, final-updated-at={user.UpdatedAt:O}"); $"original-created-at={originalCreatedAt:O}, final-updated-at={user.UpdatedAt:O}");
}
} }
} }
@@ -4,88 +4,89 @@ using Musify.Domain.ValueObjects;
using Musify.Infrastructure.Tests.TestSupport; using Musify.Infrastructure.Tests.TestSupport;
using Xunit; using Xunit;
namespace Musify.Infrastructure.Tests.Persistence; namespace Musify.Infrastructure.Tests.Persistence
[Collection(InfrastructureCollection.Name)]
public sealed class DatabaseTests(InfrastructureTestFixture fixture)
{ {
[Fact] [Collection(InfrastructureCollection.Name)]
public async Task SaveChangesAsync_LocalTrackWithOwnedTypes_RoundTripsThroughPostgres() public sealed class DatabaseTests(InfrastructureTestFixture fixture)
{ {
await using var database = fixture.CreateDatabase(); [Fact]
public async Task SaveChangesAsync_LocalTrackWithOwnedTypes_RoundTripsThroughPostgres()
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "roundtrip-user", NormalizedName = "ROUNDTRIP-USER" };
await database.Users.AddAsync(user, TestContext.Current.CancellationToken);
var track = new LocalTrack
{ {
Title = "Round Trip Track", await using var database = fixture.CreateDatabase();
NormalizedTitle = "ROUND TRIP TRACK",
DurationSeconds = 123.4, var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "roundtrip-user", NormalizedName = "ROUNDTRIP-USER" };
OwnerUserId = user.Id, await database.Users.AddAsync(user, TestContext.Current.CancellationToken);
Owner = user,
Pictures = new TrackPictures { OriginalName = "cover.webp", ProcessingStatus = ProcessingStatus.Pending }, var track = new LocalTrack
Audio = new TrackAudio { OriginalName = "song.mp3", TranscodeStatus = ProcessingStatus.Pending } {
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, TestContext.Current.CancellationToken);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
await using var reloaded = fixture.CreateDatabase();
var stored = await reloaded.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken);
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), TestContext.Current.CancellationToken);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
await using var second = fixture.CreateDatabase();
await second.ExternalTracks.AddAsync(BuildExternalTrack(videoId), TestContext.Current.CancellationToken);
await Assert.ThrowsAsync<DbUpdateException>(() => second.SaveChangesAsync(TestContext.Current.CancellationToken));
}
[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(TestContext.Current.CancellationToken);
await using var deleter = fixture.CreateDatabase();
var albumToDelete = await deleter.UserAlbums.SingleAsync(a => a.Id == album.Id, TestContext.Current.CancellationToken);
deleter.UserAlbums.Remove(albumToDelete);
await deleter.SaveChangesAsync(TestContext.Current.CancellationToken);
await using var verifier = fixture.CreateDatabase();
Assert.False(await verifier.AlbumHasTracks.AnyAsync(l => l.AlbumId == album.Id, TestContext.Current.CancellationToken));
Assert.True(await verifier.ExternalTracks.AnyAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken));
}
private static ExternalTrack BuildExternalTrack(string externalId) => new()
{
Title = "External",
NormalizedTitle = "EXTERNAL",
DurationSeconds = 100,
Source = TrackSource.YouTube,
ExternalId = externalId,
Pictures = new TrackPictures(),
Audio = new TrackAudio()
}; };
await database.LocalTracks.AddAsync(track, TestContext.Current.CancellationToken);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
await using var reloaded = fixture.CreateDatabase();
var stored = await reloaded.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken);
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), TestContext.Current.CancellationToken);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
await using var second = fixture.CreateDatabase();
await second.ExternalTracks.AddAsync(BuildExternalTrack(videoId), TestContext.Current.CancellationToken);
await Assert.ThrowsAsync<DbUpdateException>(() => second.SaveChangesAsync(TestContext.Current.CancellationToken));
}
[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(TestContext.Current.CancellationToken);
await using var deleter = fixture.CreateDatabase();
var albumToDelete = await deleter.UserAlbums.SingleAsync(a => a.Id == album.Id, TestContext.Current.CancellationToken);
deleter.UserAlbums.Remove(albumToDelete);
await deleter.SaveChangesAsync(TestContext.Current.CancellationToken);
await using var verifier = fixture.CreateDatabase();
Assert.False(await verifier.AlbumHasTracks.AnyAsync(l => l.AlbumId == album.Id, TestContext.Current.CancellationToken));
Assert.True(await verifier.ExternalTracks.AnyAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken));
}
private static ExternalTrack BuildExternalTrack(string externalId) => new()
{
Title = "External",
NormalizedTitle = "EXTERNAL",
DurationSeconds = 100,
Source = TrackSource.YouTube,
ExternalId = externalId,
Pictures = new TrackPictures(),
Audio = new TrackAudio()
};
} }
@@ -2,57 +2,58 @@ using ErrorOr;
using Musify.Infrastructure.Services; using Musify.Infrastructure.Services;
using Xunit; using Xunit;
namespace Musify.Infrastructure.Tests.Services; namespace Musify.Infrastructure.Tests.Services
public sealed class DisabledYouTubeMusicServiceTests
{ {
private readonly DisabledYouTubeMusicService service = new(); public sealed class DisabledYouTubeMusicServiceTests
[Fact]
public async Task SearchSongsAsync_ReturnsForbidden()
{ {
var result = await service.SearchSongsAsync("query", string.Empty, TestContext.Current.CancellationToken); private readonly DisabledYouTubeMusicService service = new();
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); [Fact]
} public async Task SearchSongsAsync_ReturnsForbidden()
{
var result = await service.SearchSongsAsync("query", string.Empty, TestContext.Current.CancellationToken);
[Fact] Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
public async Task SearchAlbumsAsync_ReturnsForbidden() }
{
var result = await service.SearchAlbumsAsync("query", string.Empty, TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); [Fact]
} public async Task SearchAlbumsAsync_ReturnsForbidden()
{
var result = await service.SearchAlbumsAsync("query", string.Empty, TestContext.Current.CancellationToken);
[Fact] Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
public async Task GetAlbumAsync_ReturnsForbidden() }
{
var result = await service.GetAlbumAsync("album-1", TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); [Fact]
} public async Task GetAlbumAsync_ReturnsForbidden()
{
var result = await service.GetAlbumAsync("album-1", TestContext.Current.CancellationToken);
[Fact] Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
public async Task GetAudioStreamAsync_ReturnsForbidden() }
{
var result = await service.GetAudioStreamAsync("video-1", TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); [Fact]
} public async Task GetAudioStreamAsync_ReturnsForbidden()
{
var result = await service.GetAudioStreamAsync("video-1", TestContext.Current.CancellationToken);
[Fact] Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
public async Task GetSongAsync_ReturnsForbidden() }
{
var result = await service.GetSongAsync("video-1", TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); [Fact]
} public async Task GetSongAsync_ReturnsForbidden()
{
var result = await service.GetSongAsync("video-1", TestContext.Current.CancellationToken);
[Fact] Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
public void ResolveArtworkUrl_ReturnsTheUrlUnchanged() }
{
var result = service.ResolveArtworkUrl("https://img.example/thumb.jpg");
Assert.Equal("https://img.example/thumb.jpg", result); [Fact]
public void ResolveArtworkUrl_ReturnsTheUrlUnchanged()
{
var result = service.ResolveArtworkUrl("https://img.example/thumb.jpg");
Assert.Equal("https://img.example/thumb.jpg", result);
}
} }
} }
@@ -4,54 +4,55 @@ using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.PixelFormats;
using Xunit; using Xunit;
namespace Musify.Infrastructure.Tests.Services; namespace Musify.Infrastructure.Tests.Services
public sealed class PictureServiceTests
{ {
private static PictureService CreateService() => new(NullLogger<PictureService>.Instance); public sealed class PictureServiceTests
private static MemoryStream CreateSourceImage(int width, int height)
{ {
using var image = new Image<Rgba32>(width, height, new Rgba32(255, 0, 0)); private static PictureService CreateService() => new(NullLogger<PictureService>.Instance);
var stream = new MemoryStream();
image.SaveAsPng(stream);
stream.Position = 0;
return stream;
}
[Fact] private static MemoryStream CreateSourceImage(int width, int height)
public async Task ResizePictureAsWebpAsync_LargerThanTarget_ShrinksToFitWithinTheRequestedBox() {
{ using var image = new Image<Rgba32>(width, height, new Rgba32(255, 0, 0));
using var source = CreateSourceImage(800, 600); var stream = new MemoryStream();
image.SaveAsPng(stream);
stream.Position = 0;
return stream;
}
await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 200, 200, TestContext.Current.CancellationToken); [Fact]
public async Task ResizePictureAsWebpAsync_LargerThanTarget_ShrinksToFitWithinTheRequestedBox()
{
using var source = CreateSourceImage(800, 600);
using var result = await Image.LoadAsync(resized, TestContext.Current.CancellationToken); await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 200, 200, TestContext.Current.CancellationToken);
Assert.InRange(result.Width, 1, 200);
Assert.InRange(result.Height, 1, 200);
Assert.True(result.Width == 200 || result.Height == 200, "expected the longer side to land exactly on the target box");
}
[Fact] using var result = await Image.LoadAsync(resized, TestContext.Current.CancellationToken);
public async Task ResizePictureAsWebpAsync_ReturnsAValidWebpStream() Assert.InRange(result.Width, 1, 200);
{ Assert.InRange(result.Height, 1, 200);
using var source = CreateSourceImage(100, 100); Assert.True(result.Width == 200 || result.Height == 200, "expected the longer side to land exactly on the target box");
}
await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 50, 50, TestContext.Current.CancellationToken); [Fact]
public async Task ResizePictureAsWebpAsync_ReturnsAValidWebpStream()
{
using var source = CreateSourceImage(100, 100);
var format = await Image.DetectFormatAsync(resized, TestContext.Current.CancellationToken); await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 50, 50, TestContext.Current.CancellationToken);
Assert.Equal("WEBP", format?.Name, ignoreCase: true);
}
[Fact] var format = await Image.DetectFormatAsync(resized, TestContext.Current.CancellationToken);
public async Task ResizePictureAsWebpAsync_PreservesAspectRatio() Assert.Equal("WEBP", format?.Name, ignoreCase: true);
{ }
using var source = CreateSourceImage(400, 200);
await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 100, 100, TestContext.Current.CancellationToken); [Fact]
public async Task ResizePictureAsWebpAsync_PreservesAspectRatio()
{
using var source = CreateSourceImage(400, 200);
using var result = await Image.LoadAsync(resized, TestContext.Current.CancellationToken); await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 100, 100, TestContext.Current.CancellationToken);
Assert.Equal(100, result.Width);
Assert.Equal(50, result.Height); using var result = await Image.LoadAsync(resized, TestContext.Current.CancellationToken);
Assert.Equal(100, result.Width);
Assert.Equal(50, result.Height);
}
} }
} }
@@ -7,96 +7,97 @@ using Musify.Infrastructure.Services;
using Musify.Infrastructure.Tests.TestSupport; using Musify.Infrastructure.Tests.TestSupport;
using Xunit; using Xunit;
namespace Musify.Infrastructure.Tests.Services; namespace Musify.Infrastructure.Tests.Services
[Collection(InfrastructureCollection.Name)]
public sealed class PlayListPresetSeederTests(InfrastructureTestFixture fixture)
{ {
private static PlayListConfiguration NewConfiguration(bool overwritePresetPictures) => new() [Collection(InfrastructureCollection.Name)]
public sealed class PlayListPresetSeederTests(InfrastructureTestFixture fixture)
{ {
Routes = new PlayListRoutes { ParentFolders = $"PlayLists-{Guid.NewGuid():N}" }, private static PlayListConfiguration NewConfiguration(bool overwritePresetPictures) => new()
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(TestContext.Current.CancellationToken);
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, TestContext.Current.CancellationToken); Routes = new PlayListRoutes { ParentFolders = $"PlayLists-{Guid.NewGuid():N}" },
Assert.True(metadata is not null, $"expected {key} to have been seeded"); 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(TestContext.Current.CancellationToken);
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, TestContext.Current.CancellationToken);
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, TestContext.Current.CancellationToken);
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, TestContext.Current.CancellationToken);
Assert.Null(metadata);
} }
} }
[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, TestContext.Current.CancellationToken);
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, TestContext.Current.CancellationToken);
Assert.Null(metadata);
}
} }
@@ -3,78 +3,79 @@ using Microsoft.Extensions.Caching.Memory;
using Musify.Infrastructure.Services; using Musify.Infrastructure.Services;
using Xunit; using Xunit;
namespace Musify.Infrastructure.Tests.Services; namespace Musify.Infrastructure.Tests.Services
public sealed class SingleFlightCacheTests
{ {
private static SingleFlightCache CreateCache() => new(new MemoryCache(new MemoryCacheOptions())); public sealed class SingleFlightCacheTests
[Fact]
public async Task GetOrCreateAsync_SecondCallWithinDuration_ReturnsCachedValueWithoutCallingFactory()
{ {
var cache = CreateCache(); private static SingleFlightCache CreateCache() => new(new MemoryCache(new MemoryCacheOptions()));
var calls = 0;
Task<ErrorOr<int>> Factory() { calls++; return Task.FromResult<ErrorOr<int>>(calls); }
var first = await cache.GetOrCreateAsync("key", TimeSpan.FromMinutes(1), Factory); [Fact]
var second = await cache.GetOrCreateAsync("key", TimeSpan.FromMinutes(1), Factory); public async Task GetOrCreateAsync_SecondCallWithinDuration_ReturnsCachedValueWithoutCallingFactory()
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); var cache = CreateCache();
await gate.Task; var calls = 0;
return 7; 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);
} }
var first = cache.GetOrCreateAsync("concurrent-key", TimeSpan.FromMinutes(1), Factory); [Fact]
var second = cache.GetOrCreateAsync("concurrent-key", TimeSpan.FromMinutes(1), Factory); public async Task GetOrCreateAsync_ConcurrentCallsForTheSameKey_CollapseIntoASingleFactoryCall()
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++; var cache = CreateCache();
return Task.FromResult<ErrorOr<int>>(Error.Failure(description: "boom")); 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));
} }
await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory); [Fact]
await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory); 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"));
}
Assert.Equal(2, calls); await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory);
} await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory);
[Fact] Assert.Equal(2, calls);
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); [Fact]
await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory); public async Task GetOrCreateAsync_NonPositiveDuration_AlwaysCallsTheFactory()
{
var cache = CreateCache();
var calls = 0;
Task<ErrorOr<int>> Factory() { calls++; return Task.FromResult<ErrorOr<int>>(calls); }
Assert.Equal(2, calls); await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory);
await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory);
Assert.Equal(2, calls);
}
} }
} }
@@ -8,144 +8,145 @@ using Musify.Infrastructure.Services;
using Musify.Infrastructure.Tests.TestSupport; using Musify.Infrastructure.Tests.TestSupport;
using Xunit; using Xunit;
namespace Musify.Infrastructure.Tests.Services; namespace Musify.Infrastructure.Tests.Services
[Collection(InfrastructureCollection.Name)]
public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAsyncLifetime
{ {
private Database database = null!; [Collection(InfrastructureCollection.Name)]
private StorageService service = null!; public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAsyncLifetime
public ValueTask InitializeAsync()
{ {
database = fixture.CreateDatabase(); private Database database = null!;
var storageConfiguration = new InfrastructureStorageConfiguration private StorageService service = null!;
public ValueTask InitializeAsync()
{ {
Address = fixture.S3ServiceUrl, database = fixture.CreateDatabase();
AccessKey = InfrastructureTestFixture.S3AccessKey, var storageConfiguration = new InfrastructureStorageConfiguration
SecretAccessKey = InfrastructureTestFixture.S3SecretKey, {
ForcePathStyle = true, Address = fixture.S3ServiceUrl,
UseHttp = true, AccessKey = InfrastructureTestFixture.S3AccessKey,
}; SecretAccessKey = InfrastructureTestFixture.S3SecretKey,
service = new StorageService(database, fixture.CreateS3Client(), NullLogger<StorageService>.Instance, storageConfiguration); ForcePathStyle = true,
return ValueTask.CompletedTask; UseHttp = true,
} };
service = new StorageService(database, fixture.CreateS3Client(), NullLogger<StorageService>.Instance, storageConfiguration);
return ValueTask.CompletedTask;
}
public ValueTask DisposeAsync() => database.DisposeAsync(); public ValueTask DisposeAsync() => database.DisposeAsync();
[Fact] [Fact]
public async Task UploadThenGetFile_RoundTripsTheSameBytes() public async Task UploadThenGetFile_RoundTripsTheSameBytes()
{
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, TestContext.Current.CancellationToken);
await using var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
using var reader = new StreamReader(stream, Encoding.UTF8);
var readBack = await reader.ReadToEndAsync(TestContext.Current.CancellationToken);
Assert.Equal("hello seaweedfs", readBack);
}
[Fact]
public async Task HeadObjectAsync_ExistingObject_ReturnsMetadata()
{
var key = $"tests/{Guid.NewGuid():N}/head.txt";
await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
Assert.NotNull(metadata);
Assert.Equal(1, metadata.ContentLength);
}
[Fact]
public async Task HeadObjectAsync_MissingObject_ReturnsNull()
{
var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, $"tests/{Guid.NewGuid():N}/missing.txt", TestContext.Current.CancellationToken);
Assert.Null(metadata);
}
[Fact]
public async Task RemoveFileAsync_ExistingObject_DeletesIt()
{
var key = $"tests/{Guid.NewGuid():N}/to-remove.txt";
await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
await service.RemoveFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
Assert.Null(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken));
}
[Fact]
public async Task CopyFileAsync_CopiesToTheDestinationKey()
{
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, TestContext.Current.CancellationToken);
await service.CopyFileAsync(InfrastructureTestFixture.S3Bucket, sourceKey, InfrastructureTestFixture.S3Bucket, destinationKey, TestContext.Current.CancellationToken);
Assert.NotNull(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, destinationKey, TestContext.Current.CancellationToken));
}
[Fact]
public async Task ListObjectsAsync_ReturnsEveryObjectUnderThePrefix()
{
var prefix = $"tests/{Guid.NewGuid():N}";
await service.UploadFileAsync(new MemoryStream("a"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/a.txt", TestContext.Current.CancellationToken);
await service.UploadFileAsync(new MemoryStream("b"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/b.txt", TestContext.Current.CancellationToken);
var keys = new List<string>();
await foreach (var item in service.ListObjectsAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken))
keys.Add(item.Key);
Assert.Equal([$"{prefix}/a.txt", $"{prefix}/b.txt"], keys.Order());
}
[Fact]
public async Task RemoveFolderAsync_DeletesEveryObjectUnderThePrefix()
{
var prefix = $"tests/{Guid.NewGuid():N}";
await service.UploadFileAsync(new MemoryStream("a"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/a.txt", TestContext.Current.CancellationToken);
await service.UploadFileAsync(new MemoryStream("b"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/nested/b.txt", TestContext.Current.CancellationToken);
await service.RemoveFolderAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken);
var remaining = new List<string>();
await foreach (var item in service.ListObjectsAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken))
remaining.Add(item.Key);
Assert.Empty(remaining);
}
[Fact]
public async Task GetUploadUrlAsync_PresignedPut_CanActuallyUploadTheObject()
{
var key = $"tests/{Guid.NewGuid():N}/presigned.txt";
var uploadUrl = await service.GetUploadUrlAsync(
InfrastructureTestFixture.S3Bucket, key, "text/plain", TimeSpan.FromMinutes(5), TestContext.Current.CancellationToken);
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 var key = $"tests/{Guid.NewGuid():N}/round-trip.txt";
// matches the "text/plain" the presigned URL's signature was computed over (SigV4 var content = "hello seaweedfs"u8.ToArray();
// 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 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, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode); await service.UploadFileAsync(new MemoryStream(content), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
Assert.NotNull(metadata); await using var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
Assert.Equal("presigned content".Length, metadata.ContentLength); using var reader = new StreamReader(stream, Encoding.UTF8);
var readBack = await reader.ReadToEndAsync(TestContext.Current.CancellationToken);
Assert.Equal("hello seaweedfs", readBack);
}
[Fact]
public async Task HeadObjectAsync_ExistingObject_ReturnsMetadata()
{
var key = $"tests/{Guid.NewGuid():N}/head.txt";
await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
Assert.NotNull(metadata);
Assert.Equal(1, metadata.ContentLength);
}
[Fact]
public async Task HeadObjectAsync_MissingObject_ReturnsNull()
{
var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, $"tests/{Guid.NewGuid():N}/missing.txt", TestContext.Current.CancellationToken);
Assert.Null(metadata);
}
[Fact]
public async Task RemoveFileAsync_ExistingObject_DeletesIt()
{
var key = $"tests/{Guid.NewGuid():N}/to-remove.txt";
await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
await service.RemoveFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
Assert.Null(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken));
}
[Fact]
public async Task CopyFileAsync_CopiesToTheDestinationKey()
{
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, TestContext.Current.CancellationToken);
await service.CopyFileAsync(InfrastructureTestFixture.S3Bucket, sourceKey, InfrastructureTestFixture.S3Bucket, destinationKey, TestContext.Current.CancellationToken);
Assert.NotNull(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, destinationKey, TestContext.Current.CancellationToken));
}
[Fact]
public async Task ListObjectsAsync_ReturnsEveryObjectUnderThePrefix()
{
var prefix = $"tests/{Guid.NewGuid():N}";
await service.UploadFileAsync(new MemoryStream("a"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/a.txt", TestContext.Current.CancellationToken);
await service.UploadFileAsync(new MemoryStream("b"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/b.txt", TestContext.Current.CancellationToken);
var keys = new List<string>();
await foreach (var item in service.ListObjectsAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken))
keys.Add(item.Key);
Assert.Equal([$"{prefix}/a.txt", $"{prefix}/b.txt"], keys.Order());
}
[Fact]
public async Task RemoveFolderAsync_DeletesEveryObjectUnderThePrefix()
{
var prefix = $"tests/{Guid.NewGuid():N}";
await service.UploadFileAsync(new MemoryStream("a"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/a.txt", TestContext.Current.CancellationToken);
await service.UploadFileAsync(new MemoryStream("b"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/nested/b.txt", TestContext.Current.CancellationToken);
await service.RemoveFolderAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken);
var remaining = new List<string>();
await foreach (var item in service.ListObjectsAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken))
remaining.Add(item.Key);
Assert.Empty(remaining);
}
[Fact]
public async Task GetUploadUrlAsync_PresignedPut_CanActuallyUploadTheObject()
{
var key = $"tests/{Guid.NewGuid():N}/presigned.txt";
var uploadUrl = await service.GetUploadUrlAsync(
InfrastructureTestFixture.S3Bucket, key, "text/plain", TimeSpan.FromMinutes(5), TestContext.Current.CancellationToken);
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 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, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
Assert.NotNull(metadata);
Assert.Equal("presigned content".Length, metadata.ContentLength);
}
} }
} }
@@ -5,67 +5,68 @@ using Musify.Infrastructure.Configuration;
using Musify.Infrastructure.Services; using Musify.Infrastructure.Services;
using Xunit; using Xunit;
namespace Musify.Infrastructure.Tests.Services; namespace Musify.Infrastructure.Tests.Services
public sealed class StreamTicketServiceTests : IDisposable
{ {
private readonly string privateKeyPath = Path.GetTempFileName(); public sealed class StreamTicketServiceTests : IDisposable
private readonly RSA rsa = RSA.Create(2048);
public StreamTicketServiceTests() => File.WriteAllText(privateKeyPath, rsa.ExportPkcs8PrivateKeyPem());
public void Dispose()
{ {
rsa.Dispose(); private readonly string privateKeyPath = Path.GetTempFileName();
File.Delete(privateKeyPath); private readonly RSA rsa = RSA.Create(2048);
}
private StreamTicketService CreateService(int ticketTtlSeconds = 3600) => new(new StreamTicketConfiguration public StreamTicketServiceTests() => File.WriteAllText(privateKeyPath, rsa.ExportPkcs8PrivateKeyPem());
{
PrivateKeyPath = privateKeyPath,
Audience = "media-gateway",
Issuer = "musify-webapi",
TicketTtlSeconds = ticketTtlSeconds
});
[Fact] public void Dispose()
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", rsa.Dispose();
ValidAudience = "media-gateway", File.Delete(privateKeyPath);
ValidateIssuer = true, }
ValidateAudience = true,
ValidateLifetime = true, private StreamTicketService CreateService(int ticketTtlSeconds = 3600) => new(new StreamTicketConfiguration
IssuerSigningKey = new RsaSecurityKey(verifyingRsa), {
PrivateKeyPath = privateKeyPath,
Audience = "media-gateway",
Issuer = "musify-webapi",
TicketTtlSeconds = ticketTtlSeconds
}); });
Assert.True(result.IsValid); [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);
}
} }
} }
@@ -10,108 +10,109 @@ using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers; using DotNet.Testcontainers.Containers;
using Xunit; using Xunit;
namespace Musify.Infrastructure.Tests.TestSupport; 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"; /// <summary>
public const string S3SecretKey = "musify-test-secret"; /// One Postgres container and one SeaweedFS container (same image and startup
public const string S3Bucket = "musify-test-bucket"; /// command as <c>deploy/compose.yml</c>), started once for every test in the
/// <see cref="InfrastructureCollection"/> and torn down when the run finishes.
private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine") /// EF Core migrations run once here too, against the real <c>Database</c>
.WithDatabase("musify_test") /// DbContext, so a broken migration fails the whole run immediately.
.WithUsername("postgres") /// </summary>
.WithPassword("postgres") public sealed class InfrastructureTestFixture : IAsyncLifetime
.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 ValueTask InitializeAsync()
{ {
await Task.WhenAll(postgres.StartAsync(), seaweedFs.StartAsync()); public const string S3AccessKey = "musify-test";
public const string S3SecretKey = "musify-test-secret";
public const string S3Bucket = "musify-test-bucket";
await using var database = new Database(new DatabaseConfiguration { ConnectionString = ConnectionString }); private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine")
await database.Database.MigrateAsync(); .WithDatabase("musify_test")
.WithUsername("postgres")
// SeaweedFS does not auto-create buckets (see deploy/compose.yml's seaweedfs-init). .WithPassword("postgres")
using var s3 = CreateS3Client();
await s3.PutBucketAsync(S3Bucket);
}
public async ValueTask 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(); .Build();
var services = new ServiceCollection(); private readonly IContainer seaweedFs = new ContainerBuilder("chrislusf/seaweedfs:latest")
services.AddDatabase(configuration); .WithEntrypoint("/bin/sh", "-c")
return services.BuildServiceProvider().GetRequiredService<Database>(); .WithCommand(BuildSeaweedFsStartupScript())
} .WithPortBinding(9333, assignRandomHostPort: true)
.WithPortBinding(8333, assignRandomHostPort: true)
.WithWaitStrategy(Wait.ForUnixContainer()
.UntilHttpRequestIsSucceeded(request => request.ForPort(9333).ForPath("/cluster/healthz")))
.Build();
/// <summary>An <see cref="IAmazonS3"/> client configured exactly like public string ConnectionString => postgres.GetConnectionString();
/// <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() public string S3ServiceUrl => $"http://{seaweedFs.Hostname}:{seaweedFs.GetMappedPublicPort(8333)}";
{
var identity = JsonSerializer.Serialize(new public async ValueTask InitializeAsync()
{ {
identities = new[] 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 ValueTask 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
{ {
new ServiceURL = S3ServiceUrl,
{ ForcePathStyle = true,
name = "musify-test", UseHttp = true,
credentials = new[] { new { accessKey = S3AccessKey, secretKey = S3SecretKey } }, });
actions = new[] { "Admin", "Read", "Write" }
}
}
});
return $""" private static string BuildSeaweedFsStartupScript()
cat > /tmp/s3.conf <<'JSON' {
{identity} var identity = JsonSerializer.Serialize(new
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 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";
} }
} }
[CollectionDefinition(Name)]
public sealed class InfrastructureCollection : ICollectionFixture<InfrastructureTestFixture>
{
public const string Name = "Infrastructure";
}
@@ -6,141 +6,142 @@ using Musify.StreamingGateway.Authentication;
using Musify.StreamingGateway.Configuration; using Musify.StreamingGateway.Configuration;
using Xunit; using Xunit;
namespace Musify.StreamingGateway.Tests.Authentication; namespace Musify.StreamingGateway.Tests.Authentication
public sealed class TicketValidatorTests : IDisposable
{ {
private const string Issuer = "musify-webapi"; public sealed class TicketValidatorTests : IDisposable
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(); private const string Issuer = "musify-webapi";
File.Delete(publicKeyPath); private const string Audience = "media-gateway";
}
private TicketValidator CreateValidator() => new(Options.Create(new StreamTicketValidationOptions private readonly string publicKeyPath = Path.GetTempFileName();
{ private readonly RSA rsa = RSA.Create(2048);
PublicKeyPath = publicKeyPath, private readonly JsonWebTokenHandler tokenHandler = new();
Audience = Audience,
Issuer = Issuer
}));
private string IssueToken(string prefix = "Tracks/ProcessedAudios/abc/", string issuer = Issuer, string audience = Audience, public TicketValidatorTests() => File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem());
DateTime? expires = null, DateTime? notBefore = null)
{ public void Dispose()
var descriptor = new SecurityTokenDescriptor
{ {
Issuer = issuer, rsa.Dispose();
Audience = audience, File.Delete(publicKeyPath);
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] private TicketValidator CreateValidator() => new(Options.Create(new StreamTicketValidationOptions
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, PublicKeyPath = publicKeyPath,
Audience = Audience, Audience = Audience,
NotBefore = DateTime.UtcNow.AddMinutes(-1), Issuer = Issuer
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); private string IssueToken(string prefix = "Tracks/ProcessedAudios/abc/", string issuer = Issuer, string audience = Audience,
DateTime? expires = null, DateTime? notBefore = null)
Assert.Equal(string.Empty, prefix);
}
[Fact]
public async Task ValidateTicketAsync_MissingPrefixClaim_ReturnsEmptyString()
{
var descriptor = new SecurityTokenDescriptor
{ {
Issuer = Issuer, var descriptor = new SecurityTokenDescriptor
Audience = Audience, {
NotBefore = DateTime.UtcNow.AddMinutes(-1), Issuer = issuer,
Expires = DateTime.UtcNow.AddMinutes(5), Audience = audience,
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256), NotBefore = notBefore ?? DateTime.UtcNow.AddMinutes(-1),
}; Expires = expires ?? DateTime.UtcNow.AddMinutes(5),
var token = tokenHandler.CreateToken(descriptor); Claims = new Dictionary<string, object> { ["prefix"] = prefix },
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
};
return tokenHandler.CreateToken(descriptor);
}
var prefix = await CreateValidator().ValidateTicketAsync(token); [Fact]
public async Task ValidateTicketAsync_ValidToken_ReturnsThePrefixClaim()
{
var token = IssueToken(prefix: "Tracks/ProcessedAudios/xyz/");
Assert.Equal(string.Empty, prefix); 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);
}
} }
} }
@@ -9,145 +9,146 @@ using Musify.StreamingGateway.Configuration;
using Musify.StreamingGateway.Middleware; using Musify.StreamingGateway.Middleware;
using Xunit; using Xunit;
namespace Musify.StreamingGateway.Tests.Middleware; namespace Musify.StreamingGateway.Tests.Middleware
public sealed class TicketValidationMiddlewareTests : IDisposable
{ {
private const string Prefix = "Tracks/ProcessedAudios/abc"; public sealed class TicketValidationMiddlewareTests : IDisposable
private readonly string publicKeyPath = Path.GetTempFileName();
private readonly RSA rsa = RSA.Create(2048);
private readonly StreamTicketValidationOptions options;
public TicketValidationMiddlewareTests()
{ {
File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem()); private const string Prefix = "Tracks/ProcessedAudios/abc";
options = new StreamTicketValidationOptions
private readonly string publicKeyPath = Path.GetTempFileName();
private readonly RSA rsa = RSA.Create(2048);
private readonly StreamTicketValidationOptions options;
public TicketValidationMiddlewareTests()
{ {
PublicKeyPath = publicKeyPath, File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem());
Audience = "media-gateway", options = new StreamTicketValidationOptions
Issuer = "musify-webapi", {
}; PublicKeyPath = publicKeyPath,
} Audience = "media-gateway",
Issuer = "musify-webapi",
};
}
public void Dispose() public void Dispose()
{ {
rsa.Dispose(); rsa.Dispose();
File.Delete(publicKeyPath); File.Delete(publicKeyPath);
} }
private string IssueValidToken(string prefix = Prefix) => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor private string IssueValidToken(string prefix = Prefix) => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor
{ {
Issuer = options.Issuer, Issuer = options.Issuer,
Audience = options.Audience, Audience = options.Audience,
NotBefore = DateTime.UtcNow.AddMinutes(-1), NotBefore = DateTime.UtcNow.AddMinutes(-1),
Expires = DateTime.UtcNow.AddMinutes(5), Expires = DateTime.UtcNow.AddMinutes(5),
Claims = new Dictionary<string, object> { ["prefix"] = prefix }, Claims = new Dictionary<string, object> { ["prefix"] = prefix },
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256), SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
}); });
private (TicketValidationMiddleware Middleware, Func<bool> WasNextCalled) CreateMiddleware() private (TicketValidationMiddleware Middleware, Func<bool> WasNextCalled) CreateMiddleware()
{ {
var nextCalled = false; var nextCalled = false;
RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; }; RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; };
var validator = new TicketValidator(Options.Create(options)); var validator = new TicketValidator(Options.Create(options));
var middleware = new TicketValidationMiddleware(next, validator, Options.Create(options), NullLogger<TicketValidationMiddleware>.Instance); var middleware = new TicketValidationMiddleware(next, validator, Options.Create(options), NullLogger<TicketValidationMiddleware>.Instance);
return (middleware, () => nextCalled); return (middleware, () => nextCalled);
} }
[Fact] [Fact]
public async Task InvokeAsync_PathOutsideMediaPrefix_PassesThroughWithoutValidatingATicket() public async Task InvokeAsync_PathOutsideMediaPrefix_PassesThroughWithoutValidatingATicket()
{ {
var (middleware, wasNextCalled) = CreateMiddleware(); var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext(); var context = new DefaultHttpContext();
context.Request.Path = "/health"; context.Request.Path = "/health";
await middleware.InvokeAsync(context); await middleware.InvokeAsync(context);
Assert.True(wasNextCalled()); Assert.True(wasNextCalled());
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
} }
[Fact] [Fact]
public async Task InvokeAsync_NoTicket_ReturnsUnauthorized() public async Task InvokeAsync_NoTicket_ReturnsUnauthorized()
{ {
var (middleware, wasNextCalled) = CreateMiddleware(); var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext(); var context = new DefaultHttpContext();
context.Request.Path = $"/media/{Prefix}/audio.m4a"; context.Request.Path = $"/media/{Prefix}/audio.m4a";
await middleware.InvokeAsync(context); await middleware.InvokeAsync(context);
Assert.False(wasNextCalled()); Assert.False(wasNextCalled());
Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode);
} }
[Fact] [Fact]
public async Task InvokeAsync_ValidTicketAsQueryParameter_ForTheRequestedObject_PassesThroughAndStripsTheTicket() public async Task InvokeAsync_ValidTicketAsQueryParameter_ForTheRequestedObject_PassesThroughAndStripsTheTicket()
{ {
var (middleware, wasNextCalled) = CreateMiddleware(); var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext(); var context = new DefaultHttpContext();
context.Request.Path = $"/media/{Prefix}/audio.m4a"; context.Request.Path = $"/media/{Prefix}/audio.m4a";
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}&keep=1"); context.Request.QueryString = new QueryString($"?t={IssueValidToken()}&keep=1");
await middleware.InvokeAsync(context); await middleware.InvokeAsync(context);
Assert.True(wasNextCalled()); Assert.True(wasNextCalled());
Assert.Equal("keep=1", context.Request.QueryString.Value?.TrimStart('?')); Assert.Equal("keep=1", context.Request.QueryString.Value?.TrimStart('?'));
} }
[Fact] [Fact]
public async Task InvokeAsync_ValidTicketAsHeader_PassesThrough() public async Task InvokeAsync_ValidTicketAsHeader_PassesThrough()
{ {
var (middleware, wasNextCalled) = CreateMiddleware(); var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext(); var context = new DefaultHttpContext();
context.Request.Path = $"/media/{Prefix}/audio.m4a"; context.Request.Path = $"/media/{Prefix}/audio.m4a";
context.Request.Headers["X-Stream-Ticket"] = IssueValidToken(); context.Request.Headers["X-Stream-Ticket"] = IssueValidToken();
await middleware.InvokeAsync(context); await middleware.InvokeAsync(context);
Assert.True(wasNextCalled()); Assert.True(wasNextCalled());
} }
[Fact] [Fact]
public async Task InvokeAsync_ObjectKeyOutsideAuthorizedPrefix_ReturnsForbidden() public async Task InvokeAsync_ObjectKeyOutsideAuthorizedPrefix_ReturnsForbidden()
{ {
var (middleware, wasNextCalled) = CreateMiddleware(); var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext(); var context = new DefaultHttpContext();
context.Request.Path = "/media/Tracks/ProcessedAudios/someone-elses-track/audio.m4a"; context.Request.Path = "/media/Tracks/ProcessedAudios/someone-elses-track/audio.m4a";
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); context.Request.QueryString = new QueryString($"?t={IssueValidToken()}");
await middleware.InvokeAsync(context); await middleware.InvokeAsync(context);
Assert.False(wasNextCalled()); Assert.False(wasNextCalled());
Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode); Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode);
} }
[Theory] [Theory]
[InlineData($"/media/{Prefix}/../../etc/passwd")] [InlineData($"/media/{Prefix}/../../etc/passwd")]
[InlineData($"/media/{Prefix}/./audio.m4a")] [InlineData($"/media/{Prefix}/./audio.m4a")]
public async Task InvokeAsync_PathTraversalSegment_ReturnsBadRequest(string path) public async Task InvokeAsync_PathTraversalSegment_ReturnsBadRequest(string path)
{ {
var (middleware, wasNextCalled) = CreateMiddleware(); var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext(); var context = new DefaultHttpContext();
context.Request.Path = path; context.Request.Path = path;
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); context.Request.QueryString = new QueryString($"?t={IssueValidToken()}");
await middleware.InvokeAsync(context); await middleware.InvokeAsync(context);
Assert.False(wasNextCalled()); Assert.False(wasNextCalled());
Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode);
} }
[Fact] [Fact]
public async Task InvokeAsync_TicketPrefixExactlyMatchesTheRequestedObject_PassesThrough() public async Task InvokeAsync_TicketPrefixExactlyMatchesTheRequestedObject_PassesThrough()
{ {
var (middleware, wasNextCalled) = CreateMiddleware(); var (middleware, wasNextCalled) = CreateMiddleware();
var context = new DefaultHttpContext(); var context = new DefaultHttpContext();
context.Request.Path = $"/media/{Prefix}"; context.Request.Path = $"/media/{Prefix}";
context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); context.Request.QueryString = new QueryString($"?t={IssueValidToken()}");
await middleware.InvokeAsync(context); await middleware.InvokeAsync(context);
Assert.True(wasNextCalled()); Assert.True(wasNextCalled());
}
} }
} }