diff --git a/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs b/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs index 981dda9..aecba63 100644 --- a/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs +++ b/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs @@ -3,82 +3,83 @@ using Microsoft.AspNetCore.Http; using Musify.Api.Authentication; using Xunit; -namespace Musify.Api.Tests.Authentication; - -public sealed class CurrentUserTests +namespace Musify.Api.Tests.Authentication { - private static async Task BindAsync(ClaimsPrincipal principal) + public sealed class CurrentUserTests { - var context = new DefaultHttpContext { User = principal }; - var bound = await CurrentUser.BindAsync(context, parameter: null!); - return bound!; - } + private static async Task BindAsync(ClaimsPrincipal principal) + { + var context = new DefaultHttpContext { User = principal }; + var bound = await CurrentUser.BindAsync(context, parameter: null!); + return bound!; + } - private static ClaimsPrincipal AuthenticatedPrincipal(params Claim[] claims) => - new(new ClaimsIdentity(claims, authenticationType: "TestAuth")); + private static ClaimsPrincipal AuthenticatedPrincipal(params Claim[] claims) => + new(new ClaimsIdentity(claims, authenticationType: "TestAuth")); - [Fact] - public async Task BindAsync_AuthenticatedPrincipalWithValidClaims_PopulatesEveryField() - { - var principal = AuthenticatedPrincipal( - new Claim(ClaimTypes.NameIdentifier, "42"), - new Claim(ClaimTypes.Name, "jdoe"), - new Claim(ClaimTypes.GivenName, "Jane"), - new Claim(ClaimTypes.Surname, "Doe")); + [Fact] + public async Task BindAsync_AuthenticatedPrincipalWithValidClaims_PopulatesEveryField() + { + var principal = AuthenticatedPrincipal( + new Claim(ClaimTypes.NameIdentifier, "42"), + new Claim(ClaimTypes.Name, "jdoe"), + new Claim(ClaimTypes.GivenName, "Jane"), + new Claim(ClaimTypes.Surname, "Doe")); - var user = await BindAsync(principal); + var user = await BindAsync(principal); - Assert.True(user.IsAuthenticated); - Assert.Equal(42, user.Id); - Assert.Equal(42, user.RequiredId); - Assert.Equal("jdoe", user.Username); - Assert.Equal("Jane", user.FirstName); - Assert.Equal("Doe", user.LastName); - } + Assert.True(user.IsAuthenticated); + Assert.Equal(42, user.Id); + Assert.Equal(42, user.RequiredId); + Assert.Equal("jdoe", user.Username); + Assert.Equal("Jane", user.FirstName); + Assert.Equal("Doe", user.LastName); + } - [Fact] - public async Task BindAsync_AnonymousPrincipal_IsNotAuthenticatedAndHasNoId() - { - var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity())); + [Fact] + public async Task BindAsync_AnonymousPrincipal_IsNotAuthenticatedAndHasNoId() + { + var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity())); - Assert.False(user.IsAuthenticated); - Assert.Null(user.Id); - } + Assert.False(user.IsAuthenticated); + Assert.Null(user.Id); + } - [Theory] - [InlineData("not-a-number")] - [InlineData("0")] - [InlineData("")] - public async Task BindAsync_NonNumericOrZeroNameIdentifier_LeavesIdNull(string rawId) - { - var user = await BindAsync(AuthenticatedPrincipal(new Claim(ClaimTypes.NameIdentifier, rawId))); + [Theory] + [InlineData("not-a-number")] + [InlineData("0")] + [InlineData("")] + public async Task BindAsync_NonNumericOrZeroNameIdentifier_LeavesIdNull(string rawId) + { + var user = await BindAsync(AuthenticatedPrincipal(new Claim(ClaimTypes.NameIdentifier, rawId))); - Assert.Null(user.Id); - } + Assert.Null(user.Id); + } - [Fact] - public async Task RequiredId_MissingId_Throws() - { - var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity())); + [Fact] + public async Task RequiredId_MissingId_Throws() + { + var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity())); - Assert.Throws(() => user.RequiredId); - } + Assert.Throws(() => user.RequiredId); + } - [Fact] - public async Task HasClaim_ExistingClaimType_ReturnsTrue() - { - var user = await BindAsync(AuthenticatedPrincipal(new Claim("role", "admin"))); + [Fact] + public async Task HasClaim_ExistingClaimType_ReturnsTrue() + { + var user = await BindAsync(AuthenticatedPrincipal(new Claim("role", "admin"))); - Assert.True(user.HasClaim("role")); - Assert.False(user.HasClaim("missing")); - } + Assert.True(user.HasClaim("role")); + Assert.False(user.HasClaim("missing")); + } - [Fact] - public async Task FindClaim_ExistingClaimType_ReturnsItsValue() - { - var user = await BindAsync(AuthenticatedPrincipal(new Claim("picture", "https://avatar.example/jane.png"))); + [Fact] + public async Task FindClaim_ExistingClaimType_ReturnsItsValue() + { + var user = await BindAsync(AuthenticatedPrincipal(new Claim("picture", "https://avatar.example/jane.png"))); - Assert.Equal("https://avatar.example/jane.png", user.FindClaim("picture")); - Assert.Null(user.FindClaim("missing")); + Assert.Equal("https://avatar.example/jane.png", user.FindClaim("picture")); + Assert.Null(user.FindClaim("missing")); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs index 1fffda6..f5485cc 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs @@ -6,93 +6,94 @@ using Musify.Api.Tests.TestSupport; using Musify.Application.Albums.Responses; using Xunit; -namespace Musify.Api.Tests.Endpoints; - -[Collection(ApiCollection.Name)] -public sealed class AlbumEndpointsTests(ApiTestFixture fixture) +namespace Musify.Api.Tests.Endpoints { - private async Task CreateUserAsync() + [Collection(ApiCollection.Name)] + public sealed class AlbumEndpointsTests(ApiTestFixture fixture) { - var userId = Random.Shared.NextInt64(1, long.MaxValue); - var response = await fixture.CreateAnonymousClient() - .PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null)); - response.EnsureSuccessStatusCode(); - return userId; - } + private async Task CreateUserAsync() + { + var userId = Random.Shared.NextInt64(1, long.MaxValue); + var response = await fixture.CreateAnonymousClient() + .PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null)); + response.EnsureSuccessStatusCode(); + return userId; + } - [Fact] - public async Task PostAlbums_NoAuthHeader_ReturnsUnauthorized() - { - var response = await fixture.CreateAnonymousClient() - .PostAsJsonAsync("/albums", new CreateAlbumRequest("Unauthorized Album", null, null), TestContext.Current.CancellationToken); + [Fact] + public async Task PostAlbums_NoAuthHeader_ReturnsUnauthorized() + { + var response = await fixture.CreateAnonymousClient() + .PostAsJsonAsync("/albums", new CreateAlbumRequest("Unauthorized Album", null, null), TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } - [Fact] - public async Task PostAlbums_AuthenticatedExistingUser_ReturnsCreatedAlbum() - { - var userId = await CreateUserAsync(); - var client = fixture.CreateAuthenticatedClient(userId); + [Fact] + public async Task PostAlbums_AuthenticatedExistingUser_ReturnsCreatedAlbum() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); - var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024), TestContext.Current.CancellationToken); + var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024), TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.NotNull(body); - Assert.Equal("My Album", body.Title); - Assert.Equal(userId, body.OwnerUserId); - } + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(body); + Assert.Equal("My Album", body.Title); + Assert.Equal(userId, body.OwnerUserId); + } - [Fact] - public async Task PostAlbums_BlankTitle_ReturnsValidationProblem() - { - var userId = await CreateUserAsync(); - var client = fixture.CreateAuthenticatedClient(userId); + [Fact] + public async Task PostAlbums_BlankTitle_ReturnsValidationProblem() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); - var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("", null, null), 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] - public async Task GetAlbumById_AfterCreate_ReturnsIt() - { - var userId = await CreateUserAsync(); - var client = fixture.CreateAuthenticatedClient(userId); - var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Findable", null, null), TestContext.Current.CancellationToken)) - .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + [Fact] + public async Task GetAlbumById_AfterCreate_ReturnsIt() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); + var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Findable", null, null), TestContext.Current.CancellationToken)) + .Content.ReadFromJsonAsync(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] - public async Task DeleteAlbum_NotTheOwner_ReturnsUnauthorized() - { - var owner = await CreateUserAsync(); - var stranger = await CreateUserAsync(); - var created = await (await fixture.CreateAuthenticatedClient(owner).PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null), TestContext.Current.CancellationToken)) - .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + [Fact] + public async Task DeleteAlbum_NotTheOwner_ReturnsUnauthorized() + { + var owner = await CreateUserAsync(); + var stranger = await CreateUserAsync(); + var created = await (await fixture.CreateAuthenticatedClient(owner).PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null), TestContext.Current.CancellationToken)) + .Content.ReadFromJsonAsync(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] - public async Task DeleteAlbum_Owner_ReturnsNoContentAndAlbumIsGone() - { - var userId = await CreateUserAsync(); - var client = fixture.CreateAuthenticatedClient(userId); - var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Deletable", null, null), TestContext.Current.CancellationToken)) - .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + [Fact] + public async Task DeleteAlbum_Owner_ReturnsNoContentAndAlbumIsGone() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); + var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Deletable", null, null), TestContext.Current.CancellationToken)) + .Content.ReadFromJsonAsync(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 deleteResponse = await client.DeleteAsync($"/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.NotFound, getResponse.StatusCode); + Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs index 64d5d2e..a7e0fc3 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs @@ -5,69 +5,70 @@ using Musify.Api.Tests.TestSupport; using Musify.Application.Users.Responses; using Xunit; -namespace Musify.Api.Tests.Endpoints; - -[Collection(ApiCollection.Name)] -public sealed class UserEndpointsTests(ApiTestFixture fixture) +namespace Musify.Api.Tests.Endpoints { - [Fact] - public async Task PostUsers_NewUser_ReturnsCreatedWithLocationHeader() + [Collection(ApiCollection.Name)] + public sealed class UserEndpointsTests(ApiTestFixture fixture) { - var client = fixture.CreateAnonymousClient(); - var userId = Random.Shared.NextInt64(1, long.MaxValue); + [Fact] + public async Task PostUsers_NewUser_ReturnsCreatedWithLocationHeader() + { + var client = fixture.CreateAnonymousClient(); + var userId = Random.Shared.NextInt64(1, long.MaxValue); - var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Jane Doe", "Jane", "Doe"), 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($"/users/{userId}", response.Headers.Location?.OriginalString); - var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.NotNull(body); - Assert.Equal("Jane Doe", body.Name); - } + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal($"/users/{userId}", response.Headers.Location?.OriginalString); + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(body); + Assert.Equal("Jane Doe", body.Name); + } - [Fact] - public async Task PostUsers_BlankName_ReturnsValidationProblem() - { - var client = fixture.CreateAnonymousClient(); + [Fact] + public async Task PostUsers_BlankName_ReturnsValidationProblem() + { + var client = fixture.CreateAnonymousClient(); - var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(Random.Shared.NextInt64(1, long.MaxValue), "", null, null), 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] - public async Task PostUsers_DuplicateId_ReturnsConflict() - { - var client = fixture.CreateAnonymousClient(); - var userId = Random.Shared.NextInt64(1, long.MaxValue); - await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "First", null, null), TestContext.Current.CancellationToken); + [Fact] + public async Task PostUsers_DuplicateId_ReturnsConflict() + { + var client = fixture.CreateAnonymousClient(); + var userId = Random.Shared.NextInt64(1, long.MaxValue); + await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "First", null, null), 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] - public async Task GetUserById_ExistingUser_ReturnsIt() - { - var client = fixture.CreateAnonymousClient(); - var userId = Random.Shared.NextInt64(1, long.MaxValue); - await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Findable User", null, null), TestContext.Current.CancellationToken); + [Fact] + public async Task GetUserById_ExistingUser_ReturnsIt() + { + var client = fixture.CreateAnonymousClient(); + var userId = Random.Shared.NextInt64(1, long.MaxValue); + await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Findable User", null, null), 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); - var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.Equal("Findable User", body?.Name); - } + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal("Findable User", body?.Name); + } - [Fact] - public async Task GetUserById_MissingUser_ReturnsNotFound() - { - var client = fixture.CreateAnonymousClient(); + [Fact] + public async Task GetUserById_MissingUser_ReturnsNotFound() + { + 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); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Extensions/ErrorOrHttpExtensionsTests.cs b/backend/Tests/Musify.Api.Tests/Extensions/ErrorOrHttpExtensionsTests.cs index 90b884e..86a7c4e 100644 --- a/backend/Tests/Musify.Api.Tests/Extensions/ErrorOrHttpExtensionsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Extensions/ErrorOrHttpExtensionsTests.cs @@ -4,134 +4,135 @@ using Microsoft.Extensions.DependencyInjection; using Musify.Api.Extensions; using Xunit; -namespace Musify.Api.Tests.Extensions; - -public sealed class ErrorOrHttpExtensionsTests +namespace Musify.Api.Tests.Extensions { - // The concrete IResult types (NoContent, UnauthorizedHttpResult, ...) resolve logging/problem- - // details services from HttpContext.RequestServices when they execute, so it can't be empty. - private static readonly IServiceProvider Services = new ServiceCollection().AddLogging().AddProblemDetails().BuildServiceProvider(); - - private static async Task<(int StatusCode, string Body)> ExecuteAsync(IResult result) + public sealed class ErrorOrHttpExtensionsTests { - var context = new DefaultHttpContext { RequestServices = Services }; - var bodyStream = new MemoryStream(); - context.Response.Body = bodyStream; + // The concrete IResult types (NoContent, UnauthorizedHttpResult, ...) resolve logging/problem- + // details services from HttpContext.RequestServices when they execute, so it can't be empty. + private static readonly IServiceProvider Services = new ServiceCollection().AddLogging().AddProblemDetails().BuildServiceProvider(); - await result.ExecuteAsync(context); - - bodyStream.Position = 0; - using var reader = new StreamReader(bodyStream); - return (context.Response.StatusCode, await reader.ReadToEndAsync()); - } - - [Fact] - public async Task ToHttpResult_Success_ReturnsNoContent() - { - ErrorOr result = new Success(); - - var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); - - Assert.Equal(StatusCodes.Status204NoContent, statusCode); - } - - [Fact] - public async Task ToHttpResult_Value_ReturnsOkWithTheValue() - { - ErrorOr 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 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 result = Error.Unauthorized(); - - var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); - - Assert.Equal(StatusCodes.Status401Unauthorized, statusCode); - } - - [Fact] - public async Task ToHttpResult_ForbiddenError_ReturnsForbidden() - { - ErrorOr result = Error.Forbidden(); - - var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); - - Assert.Equal(StatusCodes.Status403Forbidden, statusCode); - } - - [Fact] - public async Task ToHttpResult_ConflictError_ReturnsConflict() - { - ErrorOr result = Error.Conflict(); - - var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); - - Assert.Equal(StatusCodes.Status409Conflict, statusCode); - } - - [Fact] - public async Task ToHttpResult_FailureError_ReturnsProblem500() - { - ErrorOr result = Error.Failure(); - - var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); - - Assert.Equal(StatusCodes.Status500InternalServerError, statusCode); - } - - [Fact] - public async Task ToHttpResult_ValidationErrors_ReturnsValidationProblem() - { - ErrorOr result = new List + private static async Task<(int StatusCode, string Body)> ExecuteAsync(IResult result) { - Error.Validation("Name", "Name is required"), - Error.Validation("Name", "Name is too long"), - Error.Validation("Age", "Age must be positive"), - }; + var context = new DefaultHttpContext { RequestServices = Services }; + var bodyStream = new MemoryStream(); + context.Response.Body = bodyStream; - var (statusCode, body) = await ExecuteAsync(result.ToHttpResult()); + await result.ExecuteAsync(context); - Assert.Equal(StatusCodes.Status400BadRequest, statusCode); - Assert.Contains("Name is required", body); - Assert.Contains("Age must be positive", body); - } + bodyStream.Position = 0; + using var reader = new StreamReader(bodyStream); + return (context.Response.StatusCode, await reader.ReadToEndAsync()); + } - [Fact] - public async Task ToCreatedResult_Success_ReturnsCreatedAtTheGivenLocation() - { - ErrorOr result = "the-id"; + [Fact] + public async Task ToHttpResult_Success_ReturnsNoContent() + { + ErrorOr result = new Success(); - var context = new DefaultHttpContext { RequestServices = Services, Response = { Body = new MemoryStream() } }; - await result.ToCreatedResult(value => $"/things/{value}").ExecuteAsync(context); + var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); - Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode); - Assert.Equal("/things/the-id", context.Response.Headers.Location); - } + Assert.Equal(StatusCodes.Status204NoContent, statusCode); + } - [Fact] - public async Task ToCreatedResult_Error_ReturnsTheMappedErrorStatus() - { - ErrorOr result = Error.Conflict(); + [Fact] + public async Task ToHttpResult_Value_ReturnsOkWithTheValue() + { + ErrorOr 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 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 result = Error.Unauthorized(); + + var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status401Unauthorized, statusCode); + } + + [Fact] + public async Task ToHttpResult_ForbiddenError_ReturnsForbidden() + { + ErrorOr result = Error.Forbidden(); + + var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status403Forbidden, statusCode); + } + + [Fact] + public async Task ToHttpResult_ConflictError_ReturnsConflict() + { + ErrorOr result = Error.Conflict(); + + var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status409Conflict, statusCode); + } + + [Fact] + public async Task ToHttpResult_FailureError_ReturnsProblem500() + { + ErrorOr result = Error.Failure(); + + var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status500InternalServerError, statusCode); + } + + [Fact] + public async Task ToHttpResult_ValidationErrors_ReturnsValidationProblem() + { + ErrorOr result = new List + { + 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 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 result = Error.Conflict(); + + var (statusCode, _) = await ExecuteAsync(result.ToCreatedResult(value => $"/things/{value}")); + + Assert.Equal(StatusCodes.Status409Conflict, statusCode); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Filters/ValidationFilterTests.cs b/backend/Tests/Musify.Api.Tests/Filters/ValidationFilterTests.cs index cf0bf9b..32fbe2f 100644 --- a/backend/Tests/Musify.Api.Tests/Filters/ValidationFilterTests.cs +++ b/backend/Tests/Musify.Api.Tests/Filters/ValidationFilterTests.cs @@ -6,61 +6,62 @@ using Musify.Api.Filters; using NSubstitute; using Xunit; -namespace Musify.Api.Tests.Filters; - -public sealed class ValidationFilterTests +namespace Musify.Api.Tests.Filters { - public sealed record Payload(string Name); - - // EndpointFilterInvocationContext.Create has no (HttpContext, object[]) overload — only - // Create, Create, etc. Passing an object[] to a params-object[] call site binds - // to Create with T0 inferred as object[] itself, so .Arguments ends up with one element - // (the array, boxed) instead of the array's contents — OfType() then never matches - // and the filter silently falls through to "no argument, skip validation" for every case. - private static EndpointFilterInvocationContext CreateContext(T argument) => - EndpointFilterInvocationContext.Create(new DefaultHttpContext(), argument); - - [Fact] - public async Task InvokeAsync_ValidPayload_CallsNext() + public sealed class ValidationFilterTests { - var validator = Substitute.For>(); - validator.ValidateAsync(Arg.Any(), Arg.Any()).Returns(new ValidationResult()); - var filter = new ValidationFilter(validator); - var context = CreateContext(new Payload("ok")); + public sealed record Payload(string Name); - var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult("next-called")); + // EndpointFilterInvocationContext.Create has no (HttpContext, object[]) overload — only + // Create, Create, etc. Passing an object[] to a params-object[] call site binds + // to Create with T0 inferred as object[] itself, so .Arguments ends up with one element + // (the array, boxed) instead of the array's contents — OfType() then never matches + // and the filter silently falls through to "no argument, skip validation" for every case. + private static EndpointFilterInvocationContext CreateContext(T argument) => + EndpointFilterInvocationContext.Create(new DefaultHttpContext(), argument); - Assert.Equal("next-called", result); - await validator.Received(1).ValidateAsync(Arg.Is(p => p.Name == "ok"), Arg.Any()); - } + [Fact] + public async Task InvokeAsync_ValidPayload_CallsNext() + { + var validator = Substitute.For>(); + validator.ValidateAsync(Arg.Any(), Arg.Any()).Returns(new ValidationResult()); + var filter = new ValidationFilter(validator); + var context = CreateContext(new Payload("ok")); - [Fact] - public async Task InvokeAsync_InvalidPayload_ReturnsValidationProblemWithoutCallingNext() - { - var validator = Substitute.For>(); - validator.ValidateAsync(Arg.Any(), Arg.Any()) - .Returns(new ValidationResult([new ValidationFailure("Name", "Name is required")])); - var filter = new ValidationFilter(validator); - var context = CreateContext(new Payload("")); - var nextCalled = false; + var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult("next-called")); - var result = await filter.InvokeAsync(context, _ => { nextCalled = true; return ValueTask.FromResult(null); }); + Assert.Equal("next-called", result); + await validator.Received(1).ValidateAsync(Arg.Is(p => p.Name == "ok"), Arg.Any()); + } - Assert.False(nextCalled); - var problem = Assert.IsType(result); - Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode); - } + [Fact] + public async Task InvokeAsync_InvalidPayload_ReturnsValidationProblemWithoutCallingNext() + { + var validator = Substitute.For>(); + validator.ValidateAsync(Arg.Any(), Arg.Any()) + .Returns(new ValidationResult([new ValidationFailure("Name", "Name is required")])); + var filter = new ValidationFilter(validator); + var context = CreateContext(new Payload("")); + var nextCalled = false; - [Fact] - public async Task InvokeAsync_NoMatchingArgument_CallsNextWithoutValidating() - { - var validator = Substitute.For>(); - var filter = new ValidationFilter(validator); - var context = CreateContext(42); + var result = await filter.InvokeAsync(context, _ => { nextCalled = true; return ValueTask.FromResult(null); }); - var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult("next-called")); + Assert.False(nextCalled); + var problem = Assert.IsType(result); + Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode); + } - Assert.Equal("next-called", result); - await validator.DidNotReceiveWithAnyArgs().ValidateAsync(Arg.Any(), Arg.Any()); + [Fact] + public async Task InvokeAsync_NoMatchingArgument_CallsNextWithoutValidating() + { + var validator = Substitute.For>(); + var filter = new ValidationFilter(validator); + var context = CreateContext(42); + + var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult("next-called")); + + Assert.Equal("next-called", result); + await validator.DidNotReceiveWithAnyArgs().ValidateAsync(Arg.Any(), Arg.Any()); + } } } diff --git a/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs b/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs index 7810496..0ed2877 100644 --- a/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs +++ b/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs @@ -11,111 +11,112 @@ using NSubstitute; using Testcontainers.PostgreSql; using Xunit; -namespace Musify.Api.Tests.TestSupport; - -/// -/// Boots the real Musify.Api 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. -/// -public sealed class ApiTestFixture : WebApplicationFactory, IAsyncLifetime +namespace Musify.Api.Tests.TestSupport { - private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine") - .WithDatabase("musify_api_test") - .WithUsername("postgres") - .WithPassword("postgres") - .Build(); - - public IStorageService StorageService { get; } = Substitute.For(); - - public IEventBus EventBus { get; } = Substitute.For(); - - public IYouTubeMusicService YouTubeMusicService { get; } = Substitute.For(); - - public IStreamTicketService StreamTicketService { get; } = Substitute.For(); - - public async ValueTask InitializeAsync() + /// + /// Boots the real Musify.Api 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. + /// + public sealed class ApiTestFixture : WebApplicationFactory, IAsyncLifetime { - 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(); - var database = scope.ServiceProvider.GetRequiredService(); - await database.Database.MigrateAsync(); - } + public IStorageService StorageService { get; } = Substitute.For(); - // 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(); - } + public IEventBus EventBus { get; } = Substitute.For(); - /// An with no auth header — every protected endpoint answers 401. - public HttpClient CreateAnonymousClient() => CreateClient(); + public IYouTubeMusicService YouTubeMusicService { get; } = Substitute.For(); - /// An authenticated as the given user id (see ). - 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; - } + public IStreamTicketService StreamTicketService { get; } = Substitute.For(); - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.ConfigureAppConfiguration((_, configuration) => + public async ValueTask InitializeAsync() { - configuration.AddInMemoryCollection(new Dictionary + await postgres.StartAsync(); + + using var scope = Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + 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(); + } + + /// An with no auth header — every protected endpoint answers 401. + public HttpClient CreateAnonymousClient() => CreateClient(); + + /// An authenticated as the given user id (see ). + 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(), - ["Authentication:MetadataAddress"] = "https://auth.musify.test/.well-known/openid-configuration", - ["Authentication:IssuerAddress"] = "https://auth.musify.test", - ["Authentication:AudienceAddress"] = "musify-api-test", - ["Authentication:ClientId"] = "musify-api-test", - ["Authentication:AuthorizationEndpoint"] = "https://auth.musify.test/authorize", - ["Authentication:TokenEndpoint"] = "https://auth.musify.test/token", - ["Authentication:Scopes:0"] = "openid", - ["StreamTicket:PrivateKeyPath"] = "unused-in-tests.pem", - ["OpenTelemetry:OtlpEndpoint"] = "http://localhost:4317", - ["MassTransit:Address"] = "rabbitmq://localhost:5672", - ["MassTransit:Username"] = "guest", - ["MassTransit:Password"] = "guest", - ["InfrastructureStorage:Address"] = "http://localhost:8333", - ["InfrastructureStorage:AccessKey"] = "unused-in-tests", - ["InfrastructureStorage:SecretAccessKey"] = "unused-in-tests", - ["InfrastructureStorage:ForcePathStyle"] = "true", - ["InfrastructureStorage:UseHttp"] = "true", - ["ApplicationStorage:Bucket"] = "musify-api-test", - ["StreamGateway:PublicBaseUrl"] = "http://stream.musify.test", - ["PlayList:SeedPresetPictures"] = "false", + configuration.AddInMemoryCollection(new Dictionary + { + ["Database:ConnectionString"] = postgres.GetConnectionString(), + ["Authentication:MetadataAddress"] = "https://auth.musify.test/.well-known/openid-configuration", + ["Authentication:IssuerAddress"] = "https://auth.musify.test", + ["Authentication:AudienceAddress"] = "musify-api-test", + ["Authentication:ClientId"] = "musify-api-test", + ["Authentication:AuthorizationEndpoint"] = "https://auth.musify.test/authorize", + ["Authentication:TokenEndpoint"] = "https://auth.musify.test/token", + ["Authentication:Scopes:0"] = "openid", + ["StreamTicket:PrivateKeyPath"] = "unused-in-tests.pem", + ["OpenTelemetry:OtlpEndpoint"] = "http://localhost:4317", + ["MassTransit:Address"] = "rabbitmq://localhost:5672", + ["MassTransit:Username"] = "guest", + ["MassTransit:Password"] = "guest", + ["InfrastructureStorage:Address"] = "http://localhost:8333", + ["InfrastructureStorage:AccessKey"] = "unused-in-tests", + ["InfrastructureStorage:SecretAccessKey"] = "unused-in-tests", + ["InfrastructureStorage:ForcePathStyle"] = "true", + ["InfrastructureStorage:UseHttp"] = "true", + ["ApplicationStorage:Bucket"] = "musify-api-test", + ["StreamGateway:PublicBaseUrl"] = "http://stream.musify.test", + ["PlayList:SeedPresetPictures"] = "false", + }); }); - }); - builder.ConfigureTestServices(services => - { - services.Replace(ServiceDescriptor.Singleton(StorageService)); - services.Replace(ServiceDescriptor.Scoped(_ => EventBus)); - services.Replace(ServiceDescriptor.Singleton(YouTubeMusicService)); - services.Replace(ServiceDescriptor.Singleton(StreamTicketService)); + builder.ConfigureTestServices(services => + { + services.Replace(ServiceDescriptor.Singleton(StorageService)); + services.Replace(ServiceDescriptor.Scoped(_ => EventBus)); + services.Replace(ServiceDescriptor.Singleton(YouTubeMusicService)); + services.Replace(ServiceDescriptor.Singleton(StreamTicketService)); - // Overrides the real JwtBearer scheme as the default, so requests authenticate via - // FakeAuthenticationHandler instead of needing a real token and a reachable IdP. - services - .AddAuthentication(FakeAuthenticationHandler.SchemeName) - .AddScheme(FakeAuthenticationHandler.SchemeName, _ => { }); - }); + // Overrides the real JwtBearer scheme as the default, so requests authenticate via + // FakeAuthenticationHandler instead of needing a real token and a reachable IdP. + services + .AddAuthentication(FakeAuthenticationHandler.SchemeName) + .AddScheme(FakeAuthenticationHandler.SchemeName, _ => { }); + }); + } + } + + [CollectionDefinition(Name)] + public sealed class ApiCollection : ICollectionFixture + { + public const string Name = "Api"; } } - -[CollectionDefinition(Name)] -public sealed class ApiCollection : ICollectionFixture -{ - public const string Name = "Api"; -} diff --git a/backend/Tests/Musify.Api.Tests/TestSupport/FakeAuthenticationHandler.cs b/backend/Tests/Musify.Api.Tests/TestSupport/FakeAuthenticationHandler.cs index 24f0231..d35009e 100644 --- a/backend/Tests/Musify.Api.Tests/TestSupport/FakeAuthenticationHandler.cs +++ b/backend/Tests/Musify.Api.Tests/TestSupport/FakeAuthenticationHandler.cs @@ -4,34 +4,35 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace Musify.Api.Tests.TestSupport; - -/// -/// Replaces the real JwtBearer handler in the test host: a request carrying the -/// 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 .RequireAuthorization() correctly answer 401. -/// -public sealed class FakeAuthenticationHandler( - IOptionsMonitor options, - ILoggerFactory logger, - UrlEncoder encoder) - : AuthenticationHandler(options, logger, encoder) +namespace Musify.Api.Tests.TestSupport { - public const string SchemeName = "Test"; - public const string UserIdHeader = "X-Test-User-Id"; - - protected override Task HandleAuthenticateAsync() + /// + /// Replaces the real JwtBearer handler in the test host: a request carrying the + /// 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 .RequireAuthorization() correctly answer 401. + /// + public sealed class FakeAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : AuthenticationHandler(options, logger, encoder) { - if (!Request.Headers.TryGetValue(UserIdHeader, out var userId) || string.IsNullOrEmpty(userId)) - return Task.FromResult(AuthenticateResult.NoResult()); + public const string SchemeName = "Test"; + public const string UserIdHeader = "X-Test-User-Id"; - var claims = new List { new(ClaimTypes.NameIdentifier, userId!) }; - if (Request.Headers.TryGetValue("X-Test-User-Name", out var userName) && !string.IsNullOrEmpty(userName)) - claims.Add(new Claim(ClaimTypes.Name, userName!)); + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue(UserIdHeader, out var userId) || string.IsNullOrEmpty(userId)) + return Task.FromResult(AuthenticateResult.NoResult()); - var identity = new ClaimsIdentity(claims, SchemeName); - var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName); - return Task.FromResult(AuthenticateResult.Success(ticket)); + var claims = new List { new(ClaimTypes.NameIdentifier, userId!) }; + if (Request.Headers.TryGetValue("X-Test-User-Name", out var userName) && !string.IsNullOrEmpty(userName)) + claims.Add(new Claim(ClaimTypes.Name, userName!)); + + var identity = new ClaimsIdentity(claims, SchemeName); + var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs index 6cd9099..c7008ca 100644 --- a/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs +++ b/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs @@ -2,74 +2,75 @@ using Musify.Api.DataTransferObjects.Albums; using Musify.Api.Validators.Albums; using Xunit; -namespace Musify.Api.Tests.Validators; - -public sealed class CreateAlbumRequestValidatorTests +namespace Musify.Api.Tests.Validators { - private readonly CreateAlbumRequestValidator validator = new(); - - [Fact] - public void Validate_ValidRequest_Passes() + public sealed class CreateAlbumRequestValidatorTests { - 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] - public void Validate_ValidRequestWithoutOptionalFields_Passes() - { - var result = validator.Validate(new CreateAlbumRequest("My Album", null, null)); + Assert.True(result.IsValid); + } - Assert.True(result.IsValid); - } + [Fact] + public void Validate_ValidRequestWithoutOptionalFields_Passes() + { + var result = validator.Validate(new CreateAlbumRequest("My Album", null, null)); - [Fact] - public void Validate_BlankTitle_Fails() - { - var result = validator.Validate(new CreateAlbumRequest("", null, null)); + Assert.True(result.IsValid); + } - Assert.False(result.IsValid); - Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.Title)); - } + [Fact] + public void Validate_BlankTitle_Fails() + { + var result = validator.Validate(new CreateAlbumRequest("", null, null)); - [Fact] - public void Validate_TitleOverMaxLength_Fails() - { - var result = validator.Validate(new CreateAlbumRequest(new string('a', 201), null, null)); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.Title)); + } - Assert.False(result.IsValid); - } + [Fact] + public void Validate_TitleOverMaxLength_Fails() + { + var result = validator.Validate(new CreateAlbumRequest(new string('a', 201), null, null)); - [Fact] - public void Validate_ReleaseYearBeforeTheEarliestBoundary_Fails() - { - var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest - 1)); + Assert.False(result.IsValid); + } - Assert.False(result.IsValid); - } + [Fact] + public void Validate_ReleaseYearBeforeTheEarliestBoundary_Fails() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest - 1)); - [Fact] - public void Validate_ReleaseYearAtTheEarliestBoundary_Passes() - { - var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest)); + Assert.False(result.IsValid); + } - Assert.True(result.IsValid); - } + [Fact] + public void Validate_ReleaseYearAtTheEarliestBoundary_Passes() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest)); - [Fact] - public void Validate_ReleaseYearAtTheLatestBoundary_Passes() - { - var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest)); + Assert.True(result.IsValid); + } - Assert.True(result.IsValid); - } + [Fact] + public void Validate_ReleaseYearAtTheLatestBoundary_Passes() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest)); - [Fact] - public void Validate_ReleaseYearPastTheLatestBoundary_Fails() - { - var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest + 1)); + Assert.True(result.IsValid); + } - 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); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Validators/CreateUserRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/CreateUserRequestValidatorTests.cs index 36ccb0f..fffb0bf 100644 --- a/backend/Tests/Musify.Api.Tests/Validators/CreateUserRequestValidatorTests.cs +++ b/backend/Tests/Musify.Api.Tests/Validators/CreateUserRequestValidatorTests.cs @@ -2,50 +2,51 @@ using Musify.Api.DataTransferObjects.Users; using Musify.Api.Validators.Users; using Xunit; -namespace Musify.Api.Tests.Validators; - -public sealed class CreateUserRequestValidatorTests +namespace Musify.Api.Tests.Validators { - private readonly CreateUserRequestValidator validator = new(); - - [Fact] - public void Validate_ValidRequest_Passes() + public sealed class CreateUserRequestValidatorTests { - 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] - public void Validate_ZeroId_Fails() - { - var result = validator.Validate(new CreateUserRequest(0, "Jane Doe", null, null)); + Assert.True(result.IsValid); + } - Assert.False(result.IsValid); - Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateUserRequest.Id)); - } + [Fact] + public void Validate_ZeroId_Fails() + { + var result = validator.Validate(new CreateUserRequest(0, "Jane Doe", null, null)); - [Fact] - public void Validate_BlankName_Fails() - { - var result = validator.Validate(new CreateUserRequest(1, "", null, null)); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateUserRequest.Id)); + } - Assert.False(result.IsValid); - } + [Fact] + public void Validate_BlankName_Fails() + { + var result = validator.Validate(new CreateUserRequest(1, "", null, null)); - [Fact] - public void Validate_NameOverMaxLength_Fails() - { - var result = validator.Validate(new CreateUserRequest(1, new string('a', 49), null, null)); + Assert.False(result.IsValid); + } - Assert.False(result.IsValid); - } + [Fact] + public void Validate_NameOverMaxLength_Fails() + { + var result = validator.Validate(new CreateUserRequest(1, new string('a', 49), null, null)); - [Fact] - public void Validate_OptionalNamesOmitted_Passes() - { - var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", null, null)); + Assert.False(result.IsValid); + } - 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs index 81defac..3f9d8cd 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs @@ -5,107 +5,108 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private AddTrackToAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_FirstTrack_AddsItAtNumberOne() + public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id); - var track = TestEntities.LocalTrack(owner); - await SeedAsync(owner, album, track); + private AddTrackToAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); - 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 link = Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken)); - Assert.Equal(1, link.TrackNumber); - } + var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, 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.False(result.IsError); + var link = Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal(1, link.TrackNumber); + } - 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] - 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.NotFound, result.FirstError.Type); + } - 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] - public async Task Handle_TrackMissing_ReturnsNotFound() - { - var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id); - await SeedAsync(owner, album); + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } - 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] - 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.NotFound, result.FirstError.Type); + } - 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] - 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.Unauthorized, result.FirstError.Type); + } - 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] - 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.Equal(ErrorType.Conflict, result.FirstError.Type); + } - 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 newLink = await Database.AlbumHasTracks.SingleAsync(l => l.TrackId == secondTrack.Id, TestContext.Current.CancellationToken); - Assert.Equal(4, newLink.TrackNumber); + var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, secondTrack.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var newLink = await Database.AlbumHasTracks.SingleAsync(l => l.TrackId == secondTrack.Id, TestContext.Current.CancellationToken); + Assert.Equal(4, newLink.TrackNumber); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs index 8e67e95..5984dcc 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs @@ -3,41 +3,42 @@ using Musify.Application.Albums; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class CreateAlbumCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private CreateAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_UserExists_CreatesAlbumAndReturnsIt() + public sealed class CreateAlbumCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - await SeedAsync(owner); + private CreateAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); - 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); - 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 result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var stored = await Database.UserAlbums.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal("MY ALBUM", stored.NormalizedTitle); - } + Assert.False(result.IsError); + 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); - [Fact] - public async Task Handle_UserDoesNotExist_ReturnsNotFound() - { - var command = new CreateAlbumCommand(UserId: 404, "Orphan Album", Description: null, ReleaseYear: null); + var stored = await Database.UserAlbums.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("MY ALBUM", stored.NormalizedTitle); + } - 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs index e59d827..a9f7fe5 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs @@ -3,44 +3,45 @@ using Musify.Application.Albums; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private DeleteAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_Owner_RemovesAlbum() + public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id); - await SeedAsync(owner, album); + private DeleteAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); - 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); - Assert.Null(await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken)); - } + var result = await CreateHandler().Handle(new DeleteAlbumCommand(owner.Id, album.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AlbumMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new DeleteAlbumCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Null(await Database.UserAlbums.FindAsync([album.Id], 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] - 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.NotFound, result.FirstError.Type); + } - 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); - Assert.NotNull(await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken)); + var result = await CreateHandler().Handle(new DeleteAlbumCommand(stranger.Id, album.Id), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + Assert.NotNull(await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumByIdQueryHandlerTests.cs index d3d16f1..56bca64 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumByIdQueryHandlerTests.cs @@ -4,33 +4,34 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class GetAlbumByIdQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private GetAlbumByIdQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_ExistingAlbum_ReturnsItWithTrackCountAndCovers() + public sealed class GetAlbumByIdQueryHandlerTests : HandlerTestBase { - 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 }); + private GetAlbumByIdQueryHandler CreateHandler() => new(Database); - 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); - Assert.Equal(album.Id, result.Value.Id); - Assert.Equal(1, result.Value.TrackCount); - Assert.Equal([track.Id], result.Value.CoverTrackIds); - } + var result = await CreateHandler().Handle(new GetAlbumByIdQuery(album.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AlbumMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetAlbumByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(album.Id, result.Value.Id); + Assert.Equal(1, result.Value.TrackCount); + 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs index 2625660..6c412d9 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs @@ -4,35 +4,36 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class GetAlbumTracksQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private GetAlbumTracksQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_AlbumWithTracks_ReturnsThemOrderedByTrackNumber() + public sealed class GetAlbumTracksQueryHandlerTests : HandlerTestBase { - 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 }); + private GetAlbumTracksQueryHandler CreateHandler() => new(Database); - 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); - Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title)); - } + var result = await CreateHandler().Handle(new GetAlbumTracksQuery(album.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AlbumMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetAlbumTracksQuery(Guid.NewGuid(), PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title)); + } - 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs index 696dd68..1bf6a66 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs @@ -3,31 +3,32 @@ using Musify.Application.Albums; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class GetAlbumsByUserIdQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private GetAlbumsByUserIdQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_UserWithAlbums_ReturnsOnlyTheirAlbums() + public sealed class GetAlbumsByUserIdQueryHandlerTests : HandlerTestBase { - 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")); + private GetAlbumsByUserIdQueryHandler CreateHandler() => new(Database); - 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 album = Assert.Single(result.Value.Items); - Assert.Equal("Mine", album.Title); - } + var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(owner.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_UserMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(404, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + var album = Assert.Single(result.Value.Items); + Assert.Equal("Mine", album.Title); + } - 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs index 7ea9b2b..c3f5c3e 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs @@ -2,47 +2,48 @@ using Musify.Application.Albums; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class GetAlbumsQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private GetAlbumsQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_NoTitleFilter_ReturnsAllAlbumsPaged() + public sealed class GetAlbumsQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - await SeedAsync(owner, TestEntities.UserAlbum(owner.Id, "Alpha"), TestEntities.UserAlbum(owner.Id, "Beta")); + private GetAlbumsQueryHandler CreateHandler() => new(Database); - 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); - Assert.Equal(2, result.Value.TotalItemCount); - } + var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, 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); + Assert.Equal(2, result.Value.TotalItemCount); + } - 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 album = Assert.Single(result.Value.Items); - Assert.Equal("Rock Classics", album.Title); - } + var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), 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 album = Assert.Single(result.Value.Items); + Assert.Equal("Rock Classics", album.Title); + } - 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); - Assert.Equal(2, result.Value.Items.Count); - Assert.True(result.Value.HasNextPage); + var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 2), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(2, result.Value.Items.Count); + Assert.True(result.Value.HasNextPage); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs index 09824ed..28c0c0b 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs @@ -3,58 +3,59 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private GetRecentlyListenedAlbumsQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_ListenedTracks_ReturnsTheirAlbumsMostRecentFirst() + public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase { - 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))); + private GetRecentlyListenedAlbumsQueryHandler CreateHandler() => new(Database); - 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); - Assert.Equal(["Newer", "Older"], result.Value.Select(album => album.Title)); - } + 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); + Assert.Equal(["Newer", "Older"], result.Value.Select(album => album.Title)); + } - 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); - Assert.Empty(result.Value); - } + var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), 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); + Assert.Empty(result.Value); + } - 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); - Assert.Equal(2, result.Value.Count); + var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 2), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(2, result.Value.Count); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs index 9483258..c51431a 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs @@ -5,68 +5,69 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class RemoveTrackFromAlbumCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private RemoveTrackFromAlbumCommandHandler CreateHandler() => - new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_TrackInAlbum_RemovesItAndRenumbersTheRest() + public sealed class RemoveTrackFromAlbumCommandHandlerTests : HandlerTestBase { - 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 }); + private RemoveTrackFromAlbumCommandHandler CreateHandler() => + new(Database, NoOpLogger()); - 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 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)); - } + var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, second.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AlbumMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + 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)); + } - 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] - 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.NotFound, result.FirstError.Type); + } - 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] - public async Task Handle_TrackNotInAlbum_ReturnsNotFound() - { - var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id); - await SeedAsync(owner, album); + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } - 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs index 60e379b..0975a71 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs @@ -3,55 +3,56 @@ using Musify.Application.Albums; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private UpdateAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear() + public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var album = TestEntities.UserAlbum(owner.Id, title: "Old Title", description: "Old", releaseYear: 2020); - await SeedAsync(owner, album); + private UpdateAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); - 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); - Assert.Equal("New Title", result.Value.Title); - Assert.Equal("New description", result.Value.Description); - Assert.Equal(2025, result.Value.ReleaseYear); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var stored = await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal("NEW TITLE", stored.NormalizedTitle); - } + Assert.False(result.IsError); + Assert.Equal("New Title", result.Value.Title); + Assert.Equal("New description", result.Value.Description); + Assert.Equal(2025, result.Value.ReleaseYear); - [Fact] - public async Task Handle_AlbumMissing_ReturnsNotFound() - { - var command = new UpdateAlbumCommand(1, Guid.NewGuid(), "Title", null, null); + var stored = await Database.UserAlbums.FindAsync([album.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("NEW TITLE", stored.NormalizedTitle); + } - 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] - 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.NotFound, result.FirstError.Type); + } - 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs index fe5c911..f6fddfd 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs @@ -8,99 +8,100 @@ using Musify.Domain.ValueObjects; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Mixes; - -public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Mixes { - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - - private GenerateMixesForUserCommandHandler CreateHandler() => new( - Database, youTubeMusicService, TestConfigurations.Mix(), NoOpLogger()); - - [Fact] - public async Task Handle_NoListeningHistory_DoesNotCreateAnyMix() + public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase { - var user = TestEntities.User(); - await SeedAsync(user); + private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); + private GenerateMixesForUserCommandHandler CreateHandler() => new( + Database, youTubeMusicService, TestConfigurations.Mix(), NoOpLogger()); - Assert.False(result.IsError); - Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken)); - } + [Fact] + public async Task Handle_NoListeningHistory_DoesNotCreateAnyMix() + { + var user = TestEntities.User(); + await SeedAsync(user); - [Fact] - 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); - 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); - Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken)); - } + [Fact] + 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] - public async Task Handle_SeedArtistWithYouTubeAndLocalCandidates_ReplacesPreviousMixes() - { - youTubeMusicService - .SearchSongsAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .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()).Returns(callInfo => callInfo.Arg()); + 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); + Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken)); + } - var staleMix = TestEntities.Mix(user.Id, "Stale Mix"); - var staleItem = TestEntities.MixItem(staleMix.Id, title: "Stale Item"); + [Fact] + public async Task Handle_SeedArtistWithYouTubeAndLocalCandidates_ReplacesPreviousMixes() + { + youTubeMusicService + .SearchSongsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .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()).Returns(callInfo => callInfo.Arg()); - await SeedAsync( - user, artist, listenedTrack, otherTrackByArtist, staleMix, staleItem, - new TrackArtist { TrackId = listenedTrack.Id, ArtistId = artist.Id, Position = 0 }, - new TrackArtist { TrackId = otherTrackByArtist.Id, ArtistId = artist.Id, Position = 0 }, - TestEntities.ListeningHistory(user.Id, listenedTrack.Id)); + 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"); - 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); - var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); - Assert.NotEmpty(mixes); - 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( + user, artist, listenedTrack, otherTrackByArtist, staleMix, staleItem, + new TrackArtist { TrackId = listenedTrack.Id, ArtistId = artist.Id, Position = 0 }, + new TrackArtist { TrackId = otherTrackByArtist.Id, ArtistId = artist.Id, Position = 0 }, + TestEntities.ListeningHistory(user.Id, listenedTrack.Id)); - [Fact] - 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(), Arg.Any(), Arg.Any()) - .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); + var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); + Assert.NotEmpty(mixes); + 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( - user, artist, listenedTrack, otherTrackByArtist, - new TrackArtist { TrackId = listenedTrack.Id, ArtistId = artist.Id, Position = 0 }, - new TrackArtist { TrackId = otherTrackByArtist.Id, ArtistId = artist.Id, Position = 0 }, - TestEntities.ListeningHistory(user.Id, listenedTrack.Id)); + [Fact] + 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(), Arg.Any(), Arg.Any()) + .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); - var items = await Database.MixItems.ToListAsync(TestContext.Current.CancellationToken); - Assert.NotEmpty(items); - Assert.All(items, item => Assert.Equal(MixItemSource.Musify, item.Source)); + await SeedAsync( + user, artist, listenedTrack, otherTrackByArtist, + new TrackArtist { TrackId = listenedTrack.Id, ArtistId = artist.Id, Position = 0 }, + 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)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs index 118208e..a25ceda 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs @@ -3,45 +3,46 @@ using Musify.Application.Mixes; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.Mixes; - -public sealed class GetMixByIdQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Mixes { - private GetMixByIdQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_OwnedMix_ReturnsItWithOrderedItems() + public sealed class GetMixByIdQueryHandlerTests : HandlerTestBase { - 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); + private GetMixByIdQueryHandler CreateHandler() => new(Database); - 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); - Assert.Equal("Discovery", result.Value.Title); - Assert.Equal(["First", "Second"], result.Value.Items.Select(item => item.Title)); - } + var result = await CreateHandler().Handle(new GetMixByIdQuery(owner.Id, mix.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_MixMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetMixByIdQuery(1, Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal("Discovery", result.Value.Title); + Assert.Equal(["First", "Second"], result.Value.Items.Select(item => item.Title)); + } - 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] - 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); + [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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs index 9533607..d5ec0d9 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs @@ -2,37 +2,38 @@ using Musify.Application.Mixes; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.Mixes; - -public sealed class GetMixesByUserIdQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Mixes { - private GetMixesByUserIdQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_ReturnsOnlyThatUsersMixesOrderedByPosition() + public sealed class GetMixesByUserIdQueryHandlerTests : HandlerTestBase { - 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); + private GetMixesByUserIdQueryHandler CreateHandler() => new(Database); - 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); - Assert.Equal(["First", "Second"], result.Value.Select(mix => mix.Title)); - } + 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); + Assert.Equal(["First", "Second"], result.Value.Select(mix => mix.Title)); + } - 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); - Assert.Empty(result.Value); + var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Empty(result.Value); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs index 00c9078..df88fc1 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs @@ -5,88 +5,89 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.PlayLists; - -public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.PlayLists { - private AddTrackToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_FirstTrack_AddsItAtPositionZero() + public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - var track = TestEntities.LocalTrack(owner); - await SeedAsync(owner, playList, track); + private AddTrackToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger()); - 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 link = Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken)); - Assert.Equal(0, link.Position); - } + var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.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 link = Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal(0, link.Position); + } - 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 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(owner.Id, playList.Id, newTrack.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PlayListMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + var newLink = await Database.PlayListHasTracks.SingleAsync(link => link.TrackId == newTrack.Id, TestContext.Current.CancellationToken); + Assert.Equal(1, newLink.Position); + } - 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] - 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.NotFound, result.FirstError.Type); + } - 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] - public async Task Handle_TrackMissing_ReturnsNotFound() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, playList); + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } - 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] - 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.NotFound, result.FirstError.Type); + } - 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/AddYouTubeTrackToPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/AddYouTubeTrackToPlayListCommandHandlerTests.cs index b24930b..eeb832b 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/AddYouTubeTrackToPlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/AddYouTubeTrackToPlayListCommandHandlerTests.cs @@ -8,97 +8,98 @@ using Musify.Domain.Entities; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.PlayLists; - -public sealed class AddYouTubeTrackToPlayListCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.PlayLists { - private readonly IEventBus eventBus = Substitute.For(); - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - - private AddYouTubeTrackToPlayListCommandHandler CreateHandler() + public sealed class AddYouTubeTrackToPlayListCommandHandlerTests : HandlerTestBase { - var provisioner = new YouTubeTrackProvisioner( - Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger()); + private readonly IEventBus eventBus = Substitute.For(); + private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - return new AddYouTubeTrackToPlayListCommandHandler( - Database, - eventBus, - youTubeMusicService, - provisioner, - TestConfigurations.Storage(), - TestConfigurations.Track(), - NoOpLogger()); - } + private AddYouTubeTrackToPlayListCommandHandler CreateHandler() + { + var provisioner = new YouTubeTrackProvisioner( + Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger()); - private static YouTubeSongResult BuildSong(string videoId) => new( - videoId, "Song Title", "Song Artist", "Song Album", 200, "https://img.example/thumb.jpg", false, - [new YouTubeArtistRef("artist-id", "Song Artist")]); + return new AddYouTubeTrackToPlayListCommandHandler( + Database, + eventBus, + youTubeMusicService, + provisioner, + TestConfigurations.Storage(), + TestConfigurations.Track(), + NoOpLogger()); + } - [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); + private static YouTubeSongResult BuildSong(string videoId) => new( + videoId, "Song Title", "Song Artist", "Song Album", 200, "https://img.example/thumb.jpg", false, + [new YouTubeArtistRef("artist-id", "Song Artist")]); - 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); - Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken)); - await eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); - } + var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "abc123"), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_UnknownVideo_ProvisionsTrackAndQueuesDownload() - { - youTubeMusicService.GetSongAsync("new-video", Arg.Any()).Returns(BuildSong("new-video")); - youTubeMusicService.ResolveArtworkUrl(Arg.Any()).Returns(callInfo => callInfo.Arg()); + Assert.False(result.IsError); + Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken)); + await eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); + } - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, playList); + [Fact] + public async Task Handle_UnknownVideo_ProvisionsTrackAndQueuesDownload() + { + youTubeMusicService.GetSongAsync("new-video", Arg.Any()).Returns(BuildSong("new-video")); + youTubeMusicService.ResolveArtworkUrl(Arg.Any()).Returns(callInfo => callInfo.Arg()); - 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); - 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(), Arg.Any()); - } + var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(owner.Id, playList.Id, "new-video"), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PlayListMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new AddYouTubeTrackToPlayListCommand(1, Guid.NewGuid(), "abc123"), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + 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(), Arg.Any()); + } - 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] - 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.NotFound, result.FirstError.Type); + } - 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] - 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.Unauthorized, result.FirstError.Type); + } - 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs index 84a8883..ee7035f 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs @@ -6,80 +6,81 @@ using Musify.Application.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.PlayLists; - -public sealed class CreatePlayListCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.PlayLists { - private readonly IEventBus eventBus = Substitute.For(); - private readonly IStorageService storageService = Substitute.For(); - - private CreatePlayListCommandHandler CreateHandler() => new( - eventBus, - Database, - new UploadIntentValidator(Database, storageService), - NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.PlayList(), - TestConfigurations.UploadIntent()); - - [Fact] - public async Task Handle_NoPictureIntent_CreatesPlayListWithPresetPictures() + public sealed class CreatePlayListCommandHandlerTests : HandlerTestBase { - var user = TestEntities.User(); - await SeedAsync(user); + private readonly IEventBus eventBus = Substitute.For(); + private readonly IStorageService storageService = Substitute.For(); - var command = new CreatePlayListCommand(user.Id, "My Playlist", "A description", PictureIntentId: null); + private CreatePlayListCommandHandler CreateHandler() => new( + eventBus, + Database, + new UploadIntentValidator(Database, storageService), + NoOpLogger(), + 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); - Assert.Equal("My Playlist", result.Value.Name); - Assert.Equal("PresetSmallPicture.webp", result.Value.SmallImageKeyName); - await eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); - } + var command = new CreatePlayListCommand(user.Id, "My Playlist", "A description", PictureIntentId: null); - [Fact] - public async Task Handle_WithValidPictureIntent_PublishesResourcesEvent() - { - storageService - .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .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); + Assert.Equal("My Playlist", result.Value.Name); + Assert.Equal("PresetSmallPicture.webp", result.Value.SmallImageKeyName); + await eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); + } - var command = new CreatePlayListCommand(user.Id, "My Playlist", null, intent.Id); + [Fact] + public async Task Handle_WithValidPictureIntent_PublishesResourcesEvent() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .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 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(), Arg.Any()); - } + var command = new CreatePlayListCommand(user.Id, "My Playlist", null, intent.Id); - [Fact] - public async Task Handle_UserMissing_ReturnsNotFound() - { - var command = new CreatePlayListCommand(404, "Orphan Playlist", null, null); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - 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(), Arg.Any()); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var command = new CreatePlayListCommand(404, "Orphan Playlist", null, null); - [Fact] - public async Task Handle_PictureIntentNotFound_ReturnsError() - { - var user = TestEntities.User(); - await SeedAsync(user); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs index 7965b4e..2c637a8 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs @@ -6,48 +6,49 @@ using Musify.Domain.ValueObjects; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.PlayLists; - -public sealed class DeletePlayListCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.PlayLists { - private readonly IEventBus eventBus = Substitute.For(); - - private DeletePlayListCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); - - [Fact] - public async Task Handle_Owner_MarksPlayListAsRemoving() + public sealed class DeletePlayListCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, playList); + private readonly IEventBus eventBus = Substitute.For(); - var result = await CreateHandler().Handle(new DeletePlayListCommand(owner.Id, playList.Id), TestContext.Current.CancellationToken); + private DeletePlayListCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); - 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(), Arg.Any()); - } + [Fact] + public async Task Handle_Owner_MarksPlayListAsRemoving() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, playList); - [Fact] - public async Task Handle_PlayListMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new DeletePlayListCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new DeletePlayListCommand(owner.Id, playList.Id), 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(), Arg.Any()); + } - [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); + [Fact] + public async Task Handle_PlayListMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new DeletePlayListCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken); - 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs index 22d08d8..5a68047 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs @@ -4,32 +4,33 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.PlayLists; - -public sealed class GetPlayListByIdQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.PlayLists { - private GetPlayListByIdQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_ExistingPlayList_ReturnsItWithCoverTrackIds() + public sealed class GetPlayListByIdQueryHandlerTests : HandlerTestBase { - 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 }); + private GetPlayListByIdQueryHandler CreateHandler() => new(Database); - 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); - Assert.Equal(playList.Name, result.Value.Name); - Assert.Equal([track.Id], result.Value.CoverTrackIds); - } + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PlayListMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetPlayListByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(playList.Name, result.Value.Name); + Assert.Equal([track.Id], result.Value.CoverTrackIds); + } - 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs index e2f4600..591322d 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs @@ -3,35 +3,36 @@ using Musify.Application.PlayLists; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.PlayLists; - -public sealed class GetPlayListCoverQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.PlayLists { - private GetPlayListCoverQueryHandler CreateHandler() => - 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) + public sealed class GetPlayListCoverQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, playList); + private GetPlayListCoverQueryHandler CreateHandler() => + new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.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); - Assert.Equal("covers-bucket", result.Value.Bucket); - Assert.EndsWith(expectedFileName, result.Value.Key); - } + var result = await CreateHandler().Handle(new GetPlayListCoverQuery(playList.Id, size), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PlayListMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetPlayListCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal("covers-bucket", result.Value.Bucket); + Assert.EndsWith(expectedFileName, result.Value.Key); + } - 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs index 3f1e1e5..5642616 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs @@ -4,35 +4,36 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.PlayLists; - -public sealed class GetPlayListTracksQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.PlayLists { - private GetPlayListTracksQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_TracksOrderedByPosition_ReturnsThemInOrder() + public sealed class GetPlayListTracksQueryHandlerTests : HandlerTestBase { - 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 }); + private GetPlayListTracksQueryHandler CreateHandler() => new(Database); - 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); - Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title)); - } + var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PlayListMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetPlayListTracksQuery(Guid.NewGuid(), PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title)); + } - 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs index 157ff05..6a3bd17 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs @@ -3,40 +3,41 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Application.Tests.PlayLists; - -public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.PlayLists { - private GetPlayListsByUserIdQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_ReturnsOnlyActivePlayListsForThatUser() + public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase { - 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")); + private GetPlayListsByUserIdQueryHandler CreateHandler() => new(Database); - 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 playList = Assert.Single(result.Value.Items); - Assert.Equal("Mine", playList.Name); - } + var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, 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 playList = Assert.Single(result.Value.Items); + Assert.Equal("Mine", playList.Name); + } - 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 playList = Assert.Single(result.Value.Items); - Assert.Equal("Road Trip", playList.Name); + var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: "road", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var playList = Assert.Single(result.Value.Items); + Assert.Equal("Road Trip", playList.Name); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs index 3c8b364..ba64fa9 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs @@ -2,30 +2,31 @@ using Musify.Application.PlayLists; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.PlayLists; - -public sealed class GetPlayListsQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.PlayLists { - private GetPlayListsQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_ReturnsEveryPlayListPaged() + public sealed class GetPlayListsQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - await SeedAsync(owner, TestEntities.PlayList(owner.Id, "One"), TestEntities.PlayList(owner.Id, "Two")); + private GetPlayListsQueryHandler CreateHandler() => new(Database); - 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); - Assert.Equal(2, result.Value.TotalItemCount); - } + var result = await CreateHandler().Handle(new GetPlayListsQuery(PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - [Fact] - 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.Equal(2, result.Value.TotalItemCount); + } - Assert.False(result.IsError); - Assert.Empty(result.Value.Items); + [Fact] + 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs index 33311bc..81e1d59 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs @@ -4,56 +4,57 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.PlayLists; - -public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.PlayLists { - private RemoveTrackFromPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_TrackInPlayList_RemovesTheLink() + public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase { - 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 }); + private RemoveTrackFromPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger()); - 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); - Assert.Empty(Database.PlayListHasTracks); - } + var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PlayListMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Empty(Database.PlayListHasTracks); + } - 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] - 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.NotFound, result.FirstError.Type); + } - 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] - public async Task Handle_TrackNotLinked_ReturnsNotFound() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, playList); + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } - 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs index 78eab3f..7a73cb4 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs @@ -8,67 +8,68 @@ using Musify.Application.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.PlayLists; - -public sealed class RequestPlayListPictureUploadCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.PlayLists { - private readonly IStorageService storageService = Substitute.For(); - - private RequestPlayListPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) + public sealed class RequestPlayListPictureUploadCommandHandlerTests : HandlerTestBase { - storageService - .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns("https://storage.musify.test/presigned-upload"); + private readonly IStorageService storageService = Substitute.For(); - return new RequestPlayListPictureUploadCommandHandler( - Database, - new UploadIntentValidator(Database, storageService), - storageService, - NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.PlayList(), - uploadIntentConfig ?? TestConfigurations.UploadIntent()); - } + private RequestPlayListPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) + { + storageService + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://storage.musify.test/presigned-upload"); - [Fact] - public async Task Handle_UserExists_IssuesUploadUrlAndCreatesIntent() - { - var user = TestEntities.User(); - await SeedAsync(user); + return new RequestPlayListPictureUploadCommandHandler( + Database, + new UploadIntentValidator(Database, storageService), + storageService, + NoOpLogger(), + 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); - Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.UploadUrl); - Assert.Single(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); - } + 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.False(result.IsError); + Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.UploadUrl); + Assert.Single(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); + } - 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] - public async Task Handle_UserOverByteQuota_ReturnsValidationErrorAndCreatesNoIntent() - { - var config = TestConfigurations.UploadIntent(); - config.MaxActiveUploadBytesPerUser = 100; + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var user = TestEntities.User(); - await SeedAsync(user); + [Fact] + 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); - Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); + var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs index 0ebe7f2..683ed23 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs @@ -6,75 +6,76 @@ using Musify.Application.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.PlayLists; - -public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.PlayLists { - private readonly IEventBus eventBus = Substitute.For(); - private readonly IStorageService storageService = Substitute.For(); - - private UpdatePlayListCommandHandler CreateHandler() => new( - eventBus, - Database, - new UploadIntentValidator(Database, storageService), - NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.PlayList(), - TestConfigurations.UploadIntent()); - - [Fact] - public async Task Handle_Owner_RenamesPlayList() + public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, "Old Name"); - await SeedAsync(owner, playList); + private readonly IEventBus eventBus = Substitute.For(); + private readonly IStorageService storageService = Substitute.For(); - var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null); + private UpdatePlayListCommandHandler CreateHandler() => new( + eventBus, + Database, + new UploadIntentValidator(Database, storageService), + NoOpLogger(), + 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); - Assert.Equal("New Name", result.Value.Name); - } + var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null); - [Fact] - 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); - 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] - 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(command, TestContext.Current.CancellationToken); - 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] - 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 result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - 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); - Assert.Equal("Original Name", result.Value.Name); - Assert.Equal("Original description", result.Value.Description); + var command = new UpdatePlayListCommand(owner.Id, playList.Id, " ", " ", null); + + 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs b/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs index e0ae6e8..91dd417 100644 --- a/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs @@ -5,46 +5,47 @@ using Musify.Application.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Services; - -public sealed class TrackStreamIssuerTests : HandlerTestBase +namespace Musify.Application.Tests.Services { - private readonly IStreamTicketService ticketService = Substitute.For(); - - private TrackStreamIssuer CreateIssuer() => new( - Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway("https://stream.musify.test/")); - - [Fact] - public async Task IssueAsync_BuildsManifestUrlAndRecordsListeningHistory() + public sealed class TrackStreamIssuerTests : HandlerTestBase { - ticketService.IssueTicket(7, Arg.Any()).Returns(new StreamTicket("signed-token", 120)); - var owner = TestEntities.User(7); - var track = TestEntities.LocalTrack(owner); - await SeedAsync(owner, track); + private readonly IStreamTicketService ticketService = Substitute.For(); - 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); - Assert.Equal(120, response.ExpiresInSeconds); - Assert.Equal( - "https://stream.musify.test/media/Tracks/ProcessedAudios/folder-42/audio.m4a", - response.ManifestUrl); + [Fact] + public async Task IssueAsync_BuildsManifestUrlAndRecordsListeningHistory() + { + ticketService.IssueTicket(7, Arg.Any()).Returns(new StreamTicket("signed-token", 120)); + 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)); - Assert.Equal(7, history.UserId); - Assert.Equal(track.Id, history.TrackId); - } + var response = await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 7, TestContext.Current.CancellationToken); - [Fact] - public async Task IssueAsync_PassesTheProcessedAudioKeyPrefixToTheTicketService() - { - ticketService.IssueTicket(Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); - var owner = TestEntities.User(1); - var track = TestEntities.LocalTrack(owner); - await SeedAsync(owner, track); + Assert.Equal("signed-token", response.Ticket); + Assert.Equal(120, response.ExpiresInSeconds); + Assert.Equal( + "https://stream.musify.test/media/Tracks/ProcessedAudios/folder-42/audio.m4a", + response.ManifestUrl); - 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(), Arg.Any()).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/"); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs b/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs index e526555..441885b 100644 --- a/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs +++ b/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs @@ -6,140 +6,141 @@ using Musify.Domain.ValueObjects; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Services; - -public sealed class UploadIntentValidatorTests : HandlerTestBase +namespace Musify.Application.Tests.Services { - private readonly IStorageService storageService = Substitute.For(); - private readonly Domain.Entities.User owner = TestEntities.User(); - - private UploadIntentValidator CreateValidator() => new(Database, storageService); - - [Fact] - public async Task ValidateAndLoadAsync_UploadedObjectWithinLimits_ReturnsTheIntent() + public sealed class UploadIntentValidatorTests : HandlerTestBase { - storageService - .HeadObjectAsync("bucket", "temp/object.webp", Arg.Any()) - .Returns(new ObjectMetaData("image/webp", 2048)); + private readonly IStorageService storageService = Substitute.For(); + private readonly Domain.Entities.User owner = TestEntities.User(); - var intent = TestEntities.UploadIntent(owner.Id, bucket: "bucket", key: "temp/object.webp"); - await SeedAsync(owner, intent); + private UploadIntentValidator CreateValidator() => new(Database, storageService); - 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()) + .Returns(new ObjectMetaData("image/webp", 2048)); - Assert.False(result.IsError); - Assert.Equal(intent.Id, result.Value.Id); - } + var intent = TestEntities.UploadIntent(owner.Id, bucket: "bucket", key: "temp/object.webp"); + await SeedAsync(owner, intent); - [Fact] - public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound() - { - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), Guid.NewGuid(), owner.Id, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + Assert.False(result.IsError); + Assert.Equal(intent.Id, result.Value.Id); + } - [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] + public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound() + { + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), Guid.NewGuid(), owner.Id, TestContext.Current.CancellationToken); - 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] - 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, stranger.Id, TestContext.Current.CancellationToken); - 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] - 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); - 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] - public async Task ValidateAndLoadAsync_ObjectNeverUploaded_ReturnsNotFound() - { - storageService - .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns((ObjectMetaData?)null); + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); - var intent = TestEntities.UploadIntent(owner.Id); - await SeedAsync(owner, intent); + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } - 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(), Arg.Any(), Arg.Any()) + .Returns((ObjectMetaData?)null); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var intent = TestEntities.UploadIntent(owner.Id); + await SeedAsync(owner, intent); - [Fact] - public async Task ValidateAndLoadAsync_UploadedObjectExceedsMaxSize_ReturnsValidationError() - { - var config = TestConfigurations.UploadIntent(); - config.MaxUploadBytes = 1024; + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); - storageService - .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ObjectMetaData("image/webp", 2048)); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var intent = TestEntities.UploadIntent(owner.Id); - await SeedAsync(owner, intent); + [Fact] + 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(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 2048)); - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } + var intent = TestEntities.UploadIntent(owner.Id); + await SeedAsync(owner, intent); - [Fact] - public async Task CheckQuotaAsync_WithinLimits_ReturnsSuccess() - { - await SeedAsync(owner); + var result = await CreateValidator().ValidateAndLoadAsync(config, intent.Id, owner.Id, TestContext.Current.CancellationToken); - 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] - 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(TestConfigurations.UploadIntent(), owner.Id, requiredBytes: 1024, requiredIntentCount: 1, TestContext.Current.CancellationToken); - 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] - 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); - 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Services/YouTubeTrackProvisionerTests.cs b/backend/Tests/Musify.Application.Tests/Services/YouTubeTrackProvisionerTests.cs index 09ffa6e..0613315 100644 --- a/backend/Tests/Musify.Application.Tests/Services/YouTubeTrackProvisionerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Services/YouTubeTrackProvisionerTests.cs @@ -5,99 +5,100 @@ using Musify.Application.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Services; - -public sealed class YouTubeTrackProvisionerTests : HandlerTestBase +namespace Musify.Application.Tests.Services { - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - - private YouTubeTrackProvisioner CreateProvisioner() => - new(Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger()); - - [Fact] - public async Task FindAsync_UnknownVideo_ReturnsNull() + public sealed class YouTubeTrackProvisionerTests : HandlerTestBase { - var track = await CreateProvisioner().FindAsync("missing-video", TestContext.Current.CancellationToken); + private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - Assert.Null(track); - } + private YouTubeTrackProvisioner CreateProvisioner() => + new(Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger()); - [Fact] - public async Task GetOrCreateAsync_KnownVideo_ReturnsExistingTrackWithoutCallingTheService() - { - var existing = TestEntities.ExternalTrack(externalId: "known-video"); - await SeedAsync(existing); + [Fact] + public async Task FindAsync_UnknownVideo_ReturnsNull() + { + var track = await CreateProvisioner().FindAsync("missing-video", TestContext.Current.CancellationToken); - var result = await CreateProvisioner().GetOrCreateAsync("known-video", TestContext.Current.CancellationToken); + Assert.Null(track); + } - Assert.False(result.IsError); - Assert.Equal(existing.Id, result.Value.Track.Id); - await youTubeMusicService.DidNotReceiveWithAnyArgs().GetSongAsync(default!, TestContext.Current.CancellationToken); - } + [Fact] + public async Task GetOrCreateAsync_KnownVideo_ReturnsExistingTrackWithoutCallingTheService() + { + var existing = TestEntities.ExternalTrack(externalId: "known-video"); + await SeedAsync(existing); - [Fact] - public async Task GetOrCreateAsync_NewVideo_CreatesTrackAndDeduplicatesArtistsByName() - { - youTubeMusicService.GetSongAsync("new-video", Arg.Any()).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("known-video", TestContext.Current.CancellationToken); - 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); - 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)); - } + [Fact] + public async Task GetOrCreateAsync_NewVideo_CreatesTrackAndDeduplicatesArtistsByName() + { + youTubeMusicService.GetSongAsync("new-video", Arg.Any()).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] - public async Task GetOrCreateAsync_ServiceFails_ReturnsError() - { - youTubeMusicService.GetSongAsync("bad-video", Arg.Any()) - .Returns(ErrorOr.Error.NotFound(description: "Video not found")); + var result = await CreateProvisioner().GetOrCreateAsync("new-video", TestContext.Current.CancellationToken); - 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()) + .Returns(ErrorOr.Error.NotFound(description: "Video not found")); - [Fact] - 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) - ]); + var result = await CreateProvisioner().GetOrCreateAsync("bad-video", TestContext.Current.CancellationToken); - await CreateProvisioner().MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken); + Assert.True(result.IsError); + } - Assert.Equal(2, await Database.ExternalTracks.CountAsync(TestContext.Current.CancellationToken)); - Assert.Equal(2, await Database.AlbumHasTracks.CountAsync(TestContext.Current.CancellationToken)); - Assert.Single(await Database.ExternalAlbums.ToListAsync(TestContext.Current.CancellationToken)); - } + [Fact] + 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) + ]); - [Fact] - 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)]); + await CreateProvisioner().MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken); - var provisioner = CreateProvisioner(); - await provisioner.MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken); - await provisioner.MaterializeAlbumAsync("album-1", detail, TestContext.Current.CancellationToken); + Assert.Equal(2, await Database.ExternalTracks.CountAsync(TestContext.Current.CancellationToken)); + Assert.Equal(2, await Database.AlbumHasTracks.CountAsync(TestContext.Current.CancellationToken)); + Assert.Single(await Database.ExternalAlbums.ToListAsync(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)); + [Fact] + 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(); + 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)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Shared/StorageKeyTests.cs b/backend/Tests/Musify.Application.Tests/Shared/StorageKeyTests.cs index 8311815..447256b 100644 --- a/backend/Tests/Musify.Application.Tests/Shared/StorageKeyTests.cs +++ b/backend/Tests/Musify.Application.Tests/Shared/StorageKeyTests.cs @@ -1,40 +1,41 @@ using Musify.Application.Shared; using Xunit; -namespace Musify.Application.Tests.Shared; - -public sealed class StorageKeyTests +namespace Musify.Application.Tests.Shared { - [Fact] - public void Combine_MultipleSegments_JoinsThemWithForwardSlashes() + public sealed class StorageKeyTests { - 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] - public void Combine_SegmentsWithSurroundingSlashesAndWhitespace_TrimsThem() - { - var key = StorageKey.Combine(" Tracks/ ", "/SmallPictures/", "cover.webp"); + [Fact] + public void Combine_SegmentsWithSurroundingSlashesAndWhitespace_TrimsThem() + { + var key = StorageKey.Combine(" Tracks/ ", "/SmallPictures/", "cover.webp"); - Assert.Equal("Tracks/SmallPictures/cover.webp", key); - } + Assert.Equal("Tracks/SmallPictures/cover.webp", key); + } - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public void Combine_BlankSegment_IsSkipped(string? blank) - { - var key = StorageKey.Combine("Tracks", blank!, "cover.webp"); + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Combine_BlankSegment_IsSkipped(string? blank) + { + var key = StorageKey.Combine("Tracks", blank!, "cover.webp"); - Assert.Equal("Tracks/cover.webp", key); - } + Assert.Equal("Tracks/cover.webp", key); + } - [Fact] - public void Combine_NoSegments_ReturnsEmptyString() - { - Assert.Equal(string.Empty, StorageKey.Combine()); + [Fact] + public void Combine_NoSegments_ReturnsEmptyString() + { + Assert.Equal(string.Empty, StorageKey.Combine()); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs b/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs index 6006c81..151137d 100644 --- a/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs +++ b/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs @@ -4,49 +4,50 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Application.Tests.Shared; - -public sealed class TrackApplicationResponseTests +namespace Musify.Application.Tests.Shared { - [Fact] - public void FromEntity_LocalTrack_UsesOwnerNameAsArtist() + public sealed class TrackApplicationResponseTests { - var owner = TestEntities.User(1, "The Owner"); - var track = TestEntities.LocalTrack(owner); + [Fact] + 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(TrackSource.Local, response.Source); - Assert.Equal(owner.Id, response.OwnerUserId); - Assert.Equal(3, response.ListensCount); - } + Assert.Equal("The Owner", response.Artist); + Assert.Equal(TrackSource.Local, response.Source); + Assert.Equal(owner.Id, response.OwnerUserId); + Assert.Equal(3, response.ListensCount); + } - [Fact] - public void FromEntity_ExternalTrackWithArtists_JoinsThemInPositionOrder() - { - var track = TestEntities.ExternalTrack(); - var second = new Artist { Name = "Second Artist", NormalizedName = "SECOND ARTIST", ExternalId = "second" }; - var first = new Artist { Name = "First Artist", NormalizedName = "FIRST ARTIST", ExternalId = "first" }; - track.TrackArtists = - [ - 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 } - ]; + [Fact] + public void FromEntity_ExternalTrackWithArtists_JoinsThemInPositionOrder() + { + var track = TestEntities.ExternalTrack(); + var second = new Artist { Name = "Second Artist", NormalizedName = "SECOND ARTIST", ExternalId = "second" }; + var first = new Artist { Name = "First Artist", NormalizedName = "FIRST ARTIST", ExternalId = "first" }; + track.TrackArtists = + [ + 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 } + ]; - var response = TrackApplicationResponse.FromEntity(track, listensCount: 0); + var response = TrackApplicationResponse.FromEntity(track, listensCount: 0); - Assert.Equal("First Artist, Second Artist", response.Artist); - Assert.Equal(track.ExternalId, response.ExternalId); - } + Assert.Equal("First Artist, Second Artist", response.Artist); + Assert.Equal(track.ExternalId, response.ExternalId); + } - [Fact] - public void FromEntity_ExternalTrackWithNoArtists_HasNullArtist() - { - var track = TestEntities.ExternalTrack(); + [Fact] + public void FromEntity_ExternalTrackWithNoArtists_HasNullArtist() + { + 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs index 1a2269d..4752509 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs @@ -2,32 +2,33 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Xunit; -namespace Musify.Application.Tests.TestSupport; - -/// -/// Base class for handler tests: gives every test method its own empty, isolated -/// (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 . -/// -public abstract class HandlerTestBase : IAsyncLifetime +namespace Musify.Application.Tests.TestSupport { - private TestDatabase database = null!; - - protected TestDatabase Database => database; - - public async ValueTask InitializeAsync() => database = await TestDatabase.CreateAsync(); - - public ValueTask DisposeAsync() => database.DisposeAsync(); - - protected static ILogger NoOpLogger() => NullLogger.Instance; - - /// Adds the entities and persists them, so handlers under test see committed data. - protected async Task SeedAsync(params object[] entities) + /// + /// Base class for handler tests: gives every test method its own empty, isolated + /// (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 . + /// + public abstract class HandlerTestBase : IAsyncLifetime { - foreach (var entity in entities) - Database.Add(entity); + private TestDatabase database = null!; - 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 NoOpLogger() => NullLogger.Instance; + + /// Adds the entities and persists them, so handlers under test see committed data. + protected async Task SeedAsync(params object[] entities) + { + foreach (var entity in entities) + Database.Add(entity); + + await Database.SaveChangesAsync(CancellationToken.None); + } } } diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs index cec69a8..6b401b6 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs @@ -1,24 +1,25 @@ using Musify.Application.Configuration; -namespace Musify.Application.Tests.TestSupport; - -/// -/// Configuration POCOs with every required member filled and the rest left at their -/// production defaults, so handler tests don't have to know which fields matter for them. -/// -public static class TestConfigurations +namespace Musify.Application.Tests.TestSupport { - public static ApplicationStorageConfiguration Storage(string bucket = "test-bucket") => - new() { Bucket = bucket }; + /// + /// Configuration POCOs with every required member filled and the rest left at their + /// production defaults, so handler tests don't have to know which fields matter for them. + /// + 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") => - new() { PublicBaseUrl = publicBaseUrl }; + public static StreamGatewayConfiguration StreamGateway(string publicBaseUrl = "https://stream.musify.test") => + new() { PublicBaseUrl = publicBaseUrl }; + } } diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs index 5e46523..b2a913d 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs @@ -3,209 +3,210 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Domain.Entities; -namespace Musify.Application.Tests.TestSupport; - -/// -/// An backed by a private, in-memory SQLite database. -/// -/// -/// -/// exposes raw properties, so handlers -/// compose real LINQ queries (Where, Select, Include, ...) against it. -/// Substituting with a mock cannot express that — there is no -/// 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. -/// -/// -/// The model mirrors the parts of Musify.Infrastructure.Persistence.Database 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. -/// -/// -public sealed class TestDatabase : DbContext, IDatabase +namespace Musify.Application.Tests.TestSupport { - private readonly SqliteConnection connection; - - private TestDatabase(SqliteConnection connection, DbContextOptions options) - : base(options) - { - this.connection = connection; - } - /// - /// Creates a fresh, empty database with its own private connection. Call - /// (via ) to populate it, then - /// when the test is done. + /// An backed by a private, in-memory SQLite database. /// - public static async Task CreateAsync() + /// + /// + /// exposes raw properties, so handlers + /// compose real LINQ queries (Where, Select, Include, ...) against it. + /// Substituting with a mock cannot express that — there is no + /// 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. + /// + /// + /// The model mirrors the parts of Musify.Infrastructure.Persistence.Database 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. + /// + /// + public sealed class TestDatabase : DbContext, IDatabase { - var connection = new SqliteConnection("Filename=:memory:"); - await connection.OpenAsync(); + private readonly SqliteConnection connection; - var options = new DbContextOptionsBuilder() - .UseSqlite(connection) - .Options; + private TestDatabase(SqliteConnection connection, DbContextOptions options) + : base(options) + { + this.connection = connection; + } - var database = new TestDatabase(connection, options); - await database.Database.EnsureCreatedAsync(); - return database; - } + /// + /// Creates a fresh, empty database with its own private connection. Call + /// (via ) to populate it, then + /// when the test is done. + /// + public static async Task CreateAsync() + { + var connection = new SqliteConnection("Filename=:memory:"); + await connection.OpenAsync(); - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; - modelBuilder.Entity().OwnsOne(track => track.Pictures); - modelBuilder.Entity().Navigation(track => track.Pictures).IsRequired(); + var database = new TestDatabase(connection, options); + await database.Database.EnsureCreatedAsync(); + return database; + } - modelBuilder.Entity().OwnsOne(track => track.Audio); - modelBuilder.Entity().Navigation(track => track.Audio).IsRequired(); + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); - modelBuilder.Entity().ToTable("LocalTracks"); + modelBuilder.Entity().OwnsOne(track => track.Pictures); + modelBuilder.Entity().Navigation(track => track.Pictures).IsRequired(); - modelBuilder.Entity() - .HasOne(track => track.Owner) - .WithMany() - .HasForeignKey(track => track.OwnerUserId) - .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity().OwnsOne(track => track.Audio); + modelBuilder.Entity().Navigation(track => track.Audio).IsRequired(); - modelBuilder.Entity().ToTable("ExternalTracks"); + modelBuilder.Entity().ToTable("LocalTracks"); - modelBuilder.Entity() - .HasIndex(track => new { track.Source, track.ExternalId }) - .IsUnique(); + modelBuilder.Entity() + .HasOne(track => track.Owner) + .WithMany() + .HasForeignKey(track => track.OwnerUserId) + .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity() - .HasKey(trackArtist => new { trackArtist.TrackId, trackArtist.ArtistId }); + modelBuilder.Entity().ToTable("ExternalTracks"); - modelBuilder.Entity() - .HasOne(trackArtist => trackArtist.Track) - .WithMany(track => track.TrackArtists) - .HasForeignKey(trackArtist => trackArtist.TrackId) - .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasIndex(track => new { track.Source, track.ExternalId }) + .IsUnique(); - modelBuilder.Entity() - .HasOne(trackArtist => trackArtist.Artist) - .WithMany(artist => artist.TrackArtists) - .HasForeignKey(trackArtist => trackArtist.ArtistId) - .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasKey(trackArtist => new { trackArtist.TrackId, trackArtist.ArtistId }); - modelBuilder.Entity() - .HasIndex(artist => artist.ExternalId) - .IsUnique(); + modelBuilder.Entity() + .HasOne(trackArtist => trackArtist.Track) + .WithMany(track => track.TrackArtists) + .HasForeignKey(trackArtist => trackArtist.TrackId) + .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity().OwnsOne(playList => playList.Pictures); - modelBuilder.Entity().Navigation(playList => playList.Pictures).IsRequired(); + modelBuilder.Entity() + .HasOne(trackArtist => trackArtist.Artist) + .WithMany(artist => artist.TrackArtists) + .HasForeignKey(trackArtist => trackArtist.ArtistId) + .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity().OwnsOne(album => album.Pictures); - modelBuilder.Entity().Navigation(album => album.Pictures).IsRequired(false); + modelBuilder.Entity() + .HasIndex(artist => artist.ExternalId) + .IsUnique(); - modelBuilder.Entity().ToTable("UserAlbums"); + modelBuilder.Entity().OwnsOne(playList => playList.Pictures); + modelBuilder.Entity().Navigation(playList => playList.Pictures).IsRequired(); - modelBuilder.Entity() - .HasOne(album => album.Owner) - .WithMany() - .HasForeignKey(album => album.OwnerUserId) - .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity().OwnsOne(album => album.Pictures); + modelBuilder.Entity().Navigation(album => album.Pictures).IsRequired(false); - modelBuilder.Entity().ToTable("ExternalAlbums"); + modelBuilder.Entity().ToTable("UserAlbums"); - modelBuilder.Entity() - .HasIndex(album => new { album.Source, album.ExternalId }) - .IsUnique(); + modelBuilder.Entity() + .HasOne(album => album.Owner) + .WithMany() + .HasForeignKey(album => album.OwnerUserId) + .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity() - .HasOne(albumTrack => albumTrack.Album) - .WithMany(album => album.AlbumTracks) - .HasForeignKey(albumTrack => albumTrack.AlbumId) - .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity().ToTable("ExternalAlbums"); - modelBuilder.Entity() - .HasOne(albumTrack => albumTrack.Track) - .WithMany(track => track.AlbumTracks) - .HasForeignKey(albumTrack => albumTrack.TrackId) - .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasIndex(album => new { album.Source, album.ExternalId }) + .IsUnique(); - modelBuilder.Entity() - .HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId }) - .IsUnique(); + modelBuilder.Entity() + .HasOne(albumTrack => albumTrack.Album) + .WithMany(album => album.AlbumTracks) + .HasForeignKey(albumTrack => albumTrack.AlbumId) + .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() - .HasIndex(mix => new { mix.UserId, mix.Position }); + modelBuilder.Entity() + .HasOne(albumTrack => albumTrack.Track) + .WithMany(track => track.AlbumTracks) + .HasForeignKey(albumTrack => albumTrack.TrackId) + .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() - .HasOne(item => item.Mix) - .WithMany(mix => mix.Items) - .HasForeignKey(item => item.MixId) - .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId }) + .IsUnique(); - modelBuilder.Entity() - .HasOne(item => item.Track) - .WithMany() - .HasForeignKey(item => item.TrackId) - .IsRequired(false) - .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasIndex(mix => new { mix.UserId, mix.Position }); - modelBuilder.Entity() - .HasIndex(item => new { item.MixId, item.Position }); - } + modelBuilder.Entity() + .HasOne(item => item.Mix) + .WithMany(mix => mix.Items) + .HasForeignKey(item => item.MixId) + .OnDelete(DeleteBehavior.Cascade); - public DbSet Users => Set(); + modelBuilder.Entity() + .HasOne(item => item.Track) + .WithMany() + .HasForeignKey(item => item.TrackId) + .IsRequired(false) + .OnDelete(DeleteBehavior.Cascade); - public DbSet PlayLists => Set(); + modelBuilder.Entity() + .HasIndex(item => new { item.MixId, item.Position }); + } - public DbSet Albums => Set(); + public DbSet Users => Set(); - public DbSet UserAlbums => Set(); + public DbSet PlayLists => Set(); - public DbSet ExternalAlbums => Set(); + public DbSet Albums => Set(); - public DbSet AlbumHasTracks => Set(); + public DbSet UserAlbums => Set(); - public DbSet Tracks => Set(); + public DbSet ExternalAlbums => Set(); - public DbSet LocalTracks => Set(); + public DbSet AlbumHasTracks => Set(); - public DbSet ExternalTracks => Set(); + public DbSet Tracks => Set(); - public DbSet Artists => Set(); + public DbSet LocalTracks => Set(); - public DbSet TrackArtists => Set(); + public DbSet ExternalTracks => Set(); - public DbSet UserHasTracks => Set(); + public DbSet Artists => Set(); - public DbSet PlayListHasTracks => Set(); + public DbSet TrackArtists => Set(); - public DbSet Mixes => Set(); + public DbSet UserHasTracks => Set(); - public DbSet MixItems => Set(); + public DbSet PlayListHasTracks => Set(); - public DbSet Uploads => Set(); + public DbSet Mixes => Set(); - public DbSet UploadIntents => Set(); + public DbSet MixItems => Set(); - public DbSet ListeningHistories => Set(); + public DbSet Uploads => Set(); - public async Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) - { - var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); - return new SqliteDatabaseTransaction(transaction); - } + public DbSet UploadIntents => Set(); - public override async ValueTask DisposeAsync() - { - await base.DisposeAsync(); - await connection.DisposeAsync(); - } + public DbSet ListeningHistories => Set(); - private sealed class SqliteDatabaseTransaction(Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction inner) - : IDatabaseTransaction - { - public Task CommitAsync(CancellationToken cancellationToken = default) => - inner.CommitAsync(cancellationToken); + public async Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) + { + var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); + return new SqliteDatabaseTransaction(transaction); + } - 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(); + } } } diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs index 94dab2e..ca3556f 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs @@ -1,237 +1,238 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Tests.TestSupport; - -/// -/// Builds fully-populated domain entities for tests, with sensible defaults for every -/// required member. Every factory always returns a valid instance — never null — so -/// callers never need a null check on what this class hands back. -/// -public static class TestEntities +namespace Musify.Application.Tests.TestSupport { - public static User User( - long id = 1, - string name = "test-user", - string? firstName = "Test", - string? secondName = "User", - string? profilePictureUrl = null) => - new() - { - Id = id, - Name = name, - NormalizedName = name.ToUpperInvariant(), - FirstName = firstName, - SecondName = secondName, - ProfilePictureUrl = profilePictureUrl - }; - - public static TrackPictures ProcessedPictures() => new() + /// + /// Builds fully-populated domain entities for tests, with sensible defaults for every + /// required member. Every factory always returns a valid instance — never null — so + /// callers never need a null check on what this class hands back. + /// + public static class TestEntities { - OriginalName = "original.webp", - SmallName = "small.webp", - MediumName = "medium.webp", - LargeName = "large.webp", - ProcessingStatus = ProcessingStatus.Completed - }; + public static User User( + long id = 1, + string name = "test-user", + string? firstName = "Test", + 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() - { - 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() + public static TrackPictures ProcessedPictures() => new() { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - DurationSeconds = durationSeconds, - LifeCycleStatus = lifeCycleStatus, - OwnerUserId = owner.Id, - Owner = owner, - Pictures = pictures ?? ProcessedPictures(), - Audio = audio ?? ProcessedAudio() + OriginalName = "original.webp", + SmallName = "small.webp", + MediumName = "medium.webp", + LargeName = "large.webp", + ProcessingStatus = ProcessingStatus.Completed }; - public static ExternalTrack ExternalTrack( - 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() + public static TrackPictures PendingPictures() => new() { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - DurationSeconds = durationSeconds, - LifeCycleStatus = lifeCycleStatus, - Source = source, - ExternalId = externalId, - Pictures = pictures ?? ProcessedPictures(), - Audio = audio ?? ProcessedAudio() + OriginalName = "original.webp", + ProcessingStatus = ProcessingStatus.Pending }; - public static Artist Artist( - string name = "Test Artist", - string externalId = "artist-id") => - new() + public static TrackAudio ProcessedAudio(string folderName = "audio-folder") => new() { - Name = name, - NormalizedName = name.ToUpperInvariant(), - ExternalId = externalId + OriginalName = "original.mp3", + FolderName = folderName, + TranscodeStatus = ProcessingStatus.Completed }; - public static PlayListPictures PlayListPictures() => 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() + public static TrackAudio PendingAudio() => new() { - UserId = userId, - Name = name, - NormalizedName = name.ToUpperInvariant(), - Description = description, - Pictures = pictures ?? PlayListPictures(), - LifeCycleStatus = lifeCycleStatus + OriginalName = "original.mp3", + TranscodeStatus = ProcessingStatus.Pending }; - public static AlbumPictures AlbumPictures() => new() - { - OriginalName = "original.webp", - SmallName = "small.webp", - MediumName = "medium.webp", - LargeName = "large.webp" - }; + 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, + NormalizedTitle = title.ToUpperInvariant(), + DurationSeconds = durationSeconds, + LifeCycleStatus = lifeCycleStatus, + OwnerUserId = owner.Id, + Owner = owner, + Pictures = pictures ?? ProcessedPictures(), + Audio = audio ?? ProcessedAudio() + }; - public static UserAlbum UserAlbum( - long ownerUserId, - string title = "Test Album", - string? description = null, - int? releaseYear = 2024, - AlbumPictures? pictures = null) => - new() + public static ExternalTrack ExternalTrack( + 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, + 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, - NormalizedTitle = title.ToUpperInvariant(), - Description = description, - ReleaseYear = releaseYear, - OwnerUserId = ownerUserId, - Pictures = pictures ?? AlbumPictures() + OriginalName = "original.webp", + SmallName = "small.webp", + MediumName = "medium.webp", + LargeName = "large.webp" }; - public static ExternalAlbum ExternalAlbum( - string externalId = "album-id", - string title = "Test External Album", - TrackSource source = TrackSource.YouTube, - string? thumbnailUrl = null) => - new() + public static PlayList PlayList( + long userId, + string name = "Test Playlist", + string? description = null, + PlayListPictures? pictures = null, + 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, - NormalizedTitle = title.ToUpperInvariant(), - Source = source, - ExternalId = externalId, - ThumbnailUrl = thumbnailUrl + OriginalName = "original.webp", + SmallName = "small.webp", + MediumName = "medium.webp", + LargeName = "large.webp" }; - public static UploadIntent UploadIntent( - long userId, - UploadIntentPurpose purpose = UploadIntentPurpose.TrackPicture, - string bucket = "test-bucket", - string key = "temp/object.webp", - string objectName = "object.webp", - string contentType = "image/webp", - long? expectedSizeBytes = 1024, - UploadIntentStatus status = UploadIntentStatus.Issued, - DateTime? expiresAt = null) => - new() - { - UserId = userId, - Bucket = bucket, - Key = key, - ObjectName = objectName, - ContentType = contentType, - ExpectedSizeBytes = expectedSizeBytes, - Purpose = purpose, - Status = status, - ExpiresAt = expiresAt ?? DateTime.UtcNow.AddMinutes(5) - }; + public static UserAlbum UserAlbum( + long ownerUserId, + string title = "Test Album", + string? description = null, + int? releaseYear = 2024, + AlbumPictures? pictures = null) => + new() + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + Description = description, + ReleaseYear = releaseYear, + OwnerUserId = ownerUserId, + Pictures = pictures ?? AlbumPictures() + }; - public static Mix Mix( - long userId, - string title = "Test Mix", - string? subtitle = null, - int position = 0) => - new() - { - UserId = userId, - Title = title, - Subtitle = subtitle, - Position = position - }; + public static ExternalAlbum ExternalAlbum( + string externalId = "album-id", + string title = "Test External Album", + TrackSource source = TrackSource.YouTube, + string? thumbnailUrl = null) => + new() + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + Source = source, + ExternalId = externalId, + ThumbnailUrl = thumbnailUrl + }; - 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 UploadIntent UploadIntent( + long userId, + UploadIntentPurpose purpose = UploadIntentPurpose.TrackPicture, + string bucket = "test-bucket", + string key = "temp/object.webp", + string objectName = "object.webp", + string contentType = "image/webp", + long? expectedSizeBytes = 1024, + UploadIntentStatus status = UploadIntentStatus.Issued, + DateTime? expiresAt = null) => + new() + { + UserId = userId, + Bucket = bucket, + Key = key, + ObjectName = objectName, + ContentType = contentType, + ExpectedSizeBytes = expectedSizeBytes, + Purpose = purpose, + Status = status, + ExpiresAt = expiresAt ?? DateTime.UtcNow.AddMinutes(5) + }; - public static ListeningHistory ListeningHistory( - long userId, - Guid trackId, - DateTime? listenedAt = null) => - new() - { - UserId = userId, - TrackId = trackId, - ListenedAt = listenedAt ?? DateTime.UtcNow - }; + public static Mix Mix( + long userId, + string title = "Test Mix", + string? subtitle = null, + int position = 0) => + new() + { + UserId = userId, + 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 + }; + } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs index c4a48cb..90cc775 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs @@ -7,92 +7,93 @@ using Musify.Domain.ValueObjects; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Tracks; - -public sealed class CreateTrackCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Tracks { - private readonly IEventBus eventBus = Substitute.For(); - private readonly IStorageService storageService = Substitute.For(); - - private CreateTrackCommandHandler CreateHandler() + public sealed class CreateTrackCommandHandlerTests : HandlerTestBase { - storageService - .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ObjectMetaData("image/webp", 1024)); + private readonly IEventBus eventBus = Substitute.For(); + private readonly IStorageService storageService = Substitute.For(); - return new CreateTrackCommandHandler( - Database, - eventBus, - new UploadIntentValidator(Database, storageService), - NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.Track(), - TestConfigurations.UploadIntent()); - } + private CreateTrackCommandHandler CreateHandler() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); - private static (Domain.Entities.UploadIntent Picture, Domain.Entities.UploadIntent Audio) SeedIntents(long userId) => - (TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackPicture, objectName: "cover.webp"), - TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackAudio, objectName: "song.mp3")); + return new CreateTrackCommandHandler( + Database, + eventBus, + new UploadIntentValidator(Database, storageService), + NoOpLogger(), + TestConfigurations.Storage(), + TestConfigurations.Track(), + TestConfigurations.UploadIntent()); + } - [Fact] - public async Task Handle_ValidIntents_CreatesTrackAndPublishesEvent() - { - var user = TestEntities.User(); - var (picture, audio) = SeedIntents(user.Id); - await SeedAsync(user, picture, audio); + private static (Domain.Entities.UploadIntent Picture, Domain.Entities.UploadIntent Audio) SeedIntents(long userId) => + (TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackPicture, objectName: "cover.webp"), + TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackAudio, objectName: "song.mp3")); - 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); - Assert.Equal("My Song", result.Value.Title); - Assert.Equal(user.Id, result.Value.OwnerUserId); - await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var stored = await Database.LocalTracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus); - } + Assert.False(result.IsError); + Assert.Equal("My Song", result.Value.Title); + Assert.Equal(user.Id, result.Value.OwnerUserId); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); - [Fact] - public async Task Handle_UserMissing_ReturnsNotFound() - { - var command = new CreateTrackCommand(404, "Orphan Song", Guid.NewGuid(), Guid.NewGuid()); + var stored = await Database.LocalTracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus); + } - 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] - 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); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - 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); - await eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); - } + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - [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); + Assert.True(result.IsError); + await eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); + } - 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs index 3bd62e6..df70129 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs @@ -6,71 +6,72 @@ using Musify.Domain.ValueObjects; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Tracks; - -public sealed class DeleteTrackCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Tracks { - private readonly IEventBus eventBus = Substitute.For(); - - private DeleteTrackCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); - - [Fact] - public async Task Handle_Owner_MarksTrackAsRemovingAndPublishesEvent() + public sealed class DeleteTrackCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner); - await SeedAsync(owner, track); + private readonly IEventBus eventBus = Substitute.For(); - var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); + private DeleteTrackCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); - 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(), Arg.Any()); - } + [Fact] + public async Task Handle_Owner_MarksTrackAsRemovingAndPublishesEvent() + { + var owner = TestEntities.User(); + var track = TestEntities.LocalTrack(owner); + await SeedAsync(owner, track); - [Fact] - public async Task Handle_TrackMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new DeleteTrackCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), 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(), Arg.Any()); + } - [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] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new DeleteTrackCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken); - 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] - public async Task Handle_ExternalTrack_ReturnsUnauthorized() - { - var externalTrack = TestEntities.ExternalTrack(); - await SeedAsync(externalTrack); + var result = await CreateHandler().Handle(new DeleteTrackCommand(stranger.Id, track.Id), TestContext.Current.CancellationToken); - 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] - 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(1, externalTrack.Id), TestContext.Current.CancellationToken); - 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs index bcae1eb..97178d0 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs @@ -2,37 +2,38 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; using Xunit; -namespace Musify.Application.Tests.Tracks; - -public sealed class GetListeningHistoryQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Tracks { - private GetListeningHistoryQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_MultipleListens_ReturnsDistinctTracksMostRecentFirst() + public sealed class GetListeningHistoryQueryHandlerTests : HandlerTestBase { - 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)); + private GetListeningHistoryQueryHandler CreateHandler() => new(Database); - 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] - public async Task Handle_NoHistory_ReturnsEmpty() - { - var owner = TestEntities.User(); - await SeedAsync(owner); + Assert.Equal(["Newer listen", "Older listen"], result.Select(track => track.Title)); + } - 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs index 645a4f6..0e7cd05 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs @@ -3,32 +3,33 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; using Xunit; -namespace Musify.Application.Tests.Tracks; - -public sealed class GetTrackByIdQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Tracks { - private GetTrackByIdQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_LocalTrack_ReturnsItWithOwnerAsArtist() + public sealed class GetTrackByIdQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(1, "artist-name"); - var track = TestEntities.LocalTrack(owner, "My Song"); - await SeedAsync(owner, track); + private GetTrackByIdQueryHandler CreateHandler() => new(Database); - 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); - Assert.Equal("My Song", result.Value.Title); - Assert.Equal(owner.Name, result.Value.Artist); - Assert.Equal(owner.Id, result.Value.OwnerUserId); - } + var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_TrackMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetTrackByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal("My Song", result.Value.Title); + Assert.Equal(owner.Name, result.Value.Artist); + 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs index 0a524af..c1144a1 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs @@ -3,49 +3,50 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; using Xunit; -namespace Musify.Application.Tests.Tracks; - -public sealed class GetTrackCoverQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Tracks { - private GetTrackCoverQueryHandler CreateHandler() => - 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) + public sealed class GetTrackCoverQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner); - await SeedAsync(owner, track); + private GetTrackCoverQueryHandler CreateHandler() => + new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.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); - Assert.Equal("covers-bucket", result.Value.Bucket); - Assert.EndsWith(expectedFileName, result.Value.Key); - Assert.Equal("image/webp", result.Value.ContentType); - } + var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, size), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_TrackMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetTrackCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal("covers-bucket", result.Value.Bucket); + Assert.EndsWith(expectedFileName, result.Value.Key); + 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] - 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); + [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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs index 78a0006..ccfdc4b 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs @@ -7,61 +7,62 @@ using Musify.Application.Tracks; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Tracks; - -public sealed class GetTrackStreamQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Tracks { - private readonly IStreamTicketService ticketService = Substitute.For(); - - private GetTrackStreamQueryHandler CreateHandler() + public sealed class GetTrackStreamQueryHandlerTests : HandlerTestBase { - ticketService.IssueTicket(Arg.Any(), Arg.Any()).Returns(new StreamTicket("ticket-token", 60)); + private readonly IStreamTicketService ticketService = Substitute.For(); - var issuer = new TrackStreamIssuer( - Database, - ticketService, - TestConfigurations.Track(), - TestConfigurations.StreamGateway("https://stream.musify.test")); + private GetTrackStreamQueryHandler CreateHandler() + { + ticketService.IssueTicket(Arg.Any(), Arg.Any()).Returns(new StreamTicket("ticket-token", 60)); - return new GetTrackStreamQueryHandler(Database, issuer, NoOpLogger()); - } + var issuer = new TrackStreamIssuer( + Database, + ticketService, + TestConfigurations.Track(), + TestConfigurations.StreamGateway("https://stream.musify.test")); - [Fact] - public async Task Handle_ProcessedTrack_IssuesTicketAndRecordsListen() - { - var owner = TestEntities.User(); - var track = TestEntities.LocalTrack(owner); - await SeedAsync(owner, track); + return new GetTrackStreamQueryHandler(Database, issuer, NoOpLogger()); + } - 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); - Assert.Equal("ticket-token", result.Value.Ticket); - Assert.Equal(60, result.Value.ExpiresInSeconds); - Assert.Contains("audio-folder", result.Value.ManifestUrl); + var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken); - var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); - Assert.Equal(owner.Id, history.UserId); - Assert.Equal(track.Id, history.TrackId); - } + Assert.False(result.IsError); + Assert.Equal("ticket-token", result.Value.Ticket); + Assert.Equal(60, result.Value.ExpiresInSeconds); + Assert.Contains("audio-folder", result.Value.ManifestUrl); - [Fact] - public async Task Handle_TrackMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetTrackStreamQuery(Guid.NewGuid(), 1), TestContext.Current.CancellationToken); + var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal(owner.Id, history.UserId); + Assert.Equal(track.Id, history.TrackId); + } - 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] - 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.NotFound, result.FirstError.Type); + } - 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs index 5a76cf8..1a24965 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs @@ -3,43 +3,44 @@ using Musify.Application.Tracks; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Tracks; - -public sealed class GetTracksByUserIdQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Tracks { - private GetTracksByUserIdQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_ReturnsOnlyTracksLinkedToThatUser() + public sealed class GetTracksByUserIdQueryHandlerTests : HandlerTestBase { - 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 }); + private GetTracksByUserIdQueryHandler CreateHandler() => new(Database); - 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 track = Assert.Single(result.Value.Items); - Assert.Equal("Mine", track.Title); - } + var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: 1, 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 track = Assert.Single(result.Value.Items); + Assert.Equal("Mine", track.Title); + } - 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); - Assert.Equal(expectedPageNumber, result.Value.PageNumber); + var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: requestedPageNumber, PageSize: 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(expectedPageNumber, result.Value.PageNumber); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs index dd1854a..4867871 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs @@ -2,44 +2,45 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; using Xunit; -namespace Musify.Application.Tests.Tracks; - -public sealed class GetTracksQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Tracks { - private GetTracksQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns() + public sealed class GetTracksQueryHandlerTests : HandlerTestBase { - 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 }); + private GetTracksQueryHandler CreateHandler() => new(Database); - 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); - Assert.Equal(2, result.Value.TotalItemCount); - } + var result = await CreateHandler().Handle(new GetTracksQuery(Name: null, 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); + Assert.Equal(2, result.Value.TotalItemCount); + } - 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 track = Assert.Single(result.Value.Items); - Assert.Equal("Bohemian Rhapsody", track.Title); + var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var track = Assert.Single(result.Value.Items); + Assert.Equal("Bohemian Rhapsody", track.Title); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs index eab3b41..8867b86 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs @@ -9,72 +9,73 @@ using Musify.Domain.ValueObjects; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Tracks; - -public sealed class RequestTrackUploadUrlsCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Tracks { - private readonly IStorageService storageService = Substitute.For(); - - private RequestTrackUploadUrlsCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) + public sealed class RequestTrackUploadUrlsCommandHandlerTests : HandlerTestBase { - storageService - .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns("https://storage.musify.test/presigned-upload"); + private readonly IStorageService storageService = Substitute.For(); - return new RequestTrackUploadUrlsCommandHandler( - Database, - new UploadIntentValidator(Database, storageService), - storageService, - NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.Track(), - uploadIntentConfig ?? TestConfigurations.UploadIntent()); - } + private RequestTrackUploadUrlsCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) + { + storageService + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://storage.musify.test/presigned-upload"); - [Fact] - public async Task Handle_UserExists_IssuesBothUploadUrlsAndCreatesIntents() - { - var user = TestEntities.User(); - await SeedAsync(user); + return new RequestTrackUploadUrlsCommandHandler( + Database, + new UploadIntentValidator(Database, storageService), + storageService, + NoOpLogger(), + 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); - Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.PictureUploadUrl); - Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.AudioUploadUrl); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var intents = await Database.UploadIntents.Where(intent => intent.UserId == user.Id).ToListAsync(TestContext.Current.CancellationToken); - Assert.Equal(2, intents.Count); - Assert.Contains(intents, intent => intent.Purpose == UploadIntentPurpose.TrackPicture); - Assert.Contains(intents, intent => intent.Purpose == UploadIntentPurpose.TrackAudio); - } + Assert.False(result.IsError); + Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.PictureUploadUrl); + Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.AudioUploadUrl); - [Fact] - public async Task Handle_UserMissing_ReturnsNotFound() - { - var command = new RequestTrackUploadUrlsCommand(404, "webp", "image/webp", "mp3", "audio/mpeg"); + var intents = await Database.UploadIntents.Where(intent => intent.UserId == user.Id).ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal(2, intents.Count); + Assert.Contains(intents, intent => intent.Purpose == UploadIntentPurpose.TrackPicture); + 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] - public async Task Handle_UserAtActiveIntentLimit_ReturnsValidationErrorAndCreatesNothing() - { - var config = TestConfigurations.UploadIntent(); - config.MaxActiveUploadIntentsPerUser = 1; + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var user = TestEntities.User(); - await SeedAsync(user, TestEntities.UploadIntent(user.Id)); + [Fact] + 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); - Assert.Equal(1, await Database.UploadIntents.CountAsync(TestContext.Current.CancellationToken)); + var result = await CreateHandler(config).Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + Assert.Equal(1, await Database.UploadIntents.CountAsync(TestContext.Current.CancellationToken)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Users/CreateUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/CreateUserCommandHandlerTests.cs index 1dd4718..eb15fa6 100644 --- a/backend/Tests/Musify.Application.Tests/Users/CreateUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/CreateUserCommandHandlerTests.cs @@ -3,38 +3,39 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Users; using Xunit; -namespace Musify.Application.Tests.Users; - -public sealed class CreateUserCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Users { - private CreateUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_NewId_CreatesUser() + public sealed class CreateUserCommandHandlerTests : HandlerTestBase { - var command = new CreateUserCommand(1, "Jane Doe", "Jane", "Doe"); + private CreateUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); - 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); - Assert.Equal("Jane Doe", result.Value.Name); - Assert.Equal(1, result.Value.Id); + 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.NormalizedName); - } + Assert.False(result.IsError); + Assert.Equal("Jane Doe", result.Value.Name); + Assert.Equal(1, result.Value.Id); - [Fact] - public async Task Handle_IdAlreadyExists_ReturnsConflict() - { - var existing = TestEntities.User(1); - await SeedAsync(existing); + var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("JANE DOE", stored.NormalizedName); + } - 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUserByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUserByIdQueryHandlerTests.cs index 2bcb0ac..1854be6 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetUserByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetUserByIdQueryHandlerTests.cs @@ -3,29 +3,30 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Users; using Xunit; -namespace Musify.Application.Tests.Users; - -public sealed class GetUserByIdQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Users { - private GetUserByIdQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_ExistingUser_ReturnsIt() + public sealed class GetUserByIdQueryHandlerTests : HandlerTestBase { - var user = TestEntities.User(1, "Jane Doe"); - await SeedAsync(user); + private GetUserByIdQueryHandler CreateHandler() => new(Database); - 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); - Assert.Equal("Jane Doe", result.Value.Name); - } + var result = await CreateHandler().Handle(new GetUserByIdQuery(1), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_UserMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetUserByIdQuery(404), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal("Jane Doe", result.Value.Name); + } - 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs index 516e6b1..f60f180 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs @@ -2,32 +2,33 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Users; using Xunit; -namespace Musify.Application.Tests.Users; - -public sealed class GetUsersQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Users { - private GetUsersQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_NoSearch_ReturnsEveryUser() + public sealed class GetUsersQueryHandlerTests : HandlerTestBase { - 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); - Assert.Equal(2, result.Value.TotalItemCount); - } + var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null), 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); + Assert.Equal(2, result.Value.TotalItemCount); + } - 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 user = Assert.Single(result.Value.Items); - Assert.Equal(1, user.Id); + var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: "alice"), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var user = Assert.Single(result.Value.Items); + Assert.Equal(1, user.Id); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Users/SyncUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/SyncUserCommandHandlerTests.cs index 4a73c0f..3702165 100644 --- a/backend/Tests/Musify.Application.Tests/Users/SyncUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/SyncUserCommandHandlerTests.cs @@ -2,41 +2,42 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Users; using Xunit; -namespace Musify.Application.Tests.Users; - -public sealed class SyncUserCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Users { - private SyncUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_UnknownUser_ProvisionsIt() + public sealed class SyncUserCommandHandlerTests : HandlerTestBase { - var command = new SyncUserCommand(1, "Jane Doe", "Jane", "Doe", "https://avatar.example/jane.png"); + private SyncUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); - 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 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); - } + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_ExistingUser_UpdatesProfileFields() - { - var user = TestEntities.User(1, "Old Name", profilePictureUrl: null); - await SeedAsync(user); + Assert.False(result.IsError); + 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); + } - 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 stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal("New Name", stored.Name); - Assert.Equal("NEW NAME", stored.NormalizedName); - Assert.Equal("https://avatar.example/new.png", stored.ProfilePictureUrl); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("New Name", stored.Name); + Assert.Equal("NEW NAME", stored.NormalizedName); + Assert.Equal("https://avatar.example/new.png", stored.ProfilePictureUrl); + } } } diff --git a/backend/Tests/Musify.Application.Tests/YouTube/GetYouTubeAlbumQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/YouTube/GetYouTubeAlbumQueryHandlerTests.cs index b438db4..ff13a7d 100644 --- a/backend/Tests/Musify.Application.Tests/YouTube/GetYouTubeAlbumQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/YouTube/GetYouTubeAlbumQueryHandlerTests.cs @@ -7,55 +7,56 @@ using Musify.Application.YouTube; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.YouTube; - -public sealed class GetYouTubeAlbumQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.YouTube { - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - - private GetYouTubeAlbumQueryHandler CreateHandler() + public sealed class GetYouTubeAlbumQueryHandlerTests : HandlerTestBase { - var provisioner = new YouTubeTrackProvisioner( - Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger()); + private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - return new GetYouTubeAlbumQueryHandler(youTubeMusicService, provisioner, NoOpLogger()); - } + private GetYouTubeAlbumQueryHandler CreateHandler() + { + var provisioner = new YouTubeTrackProvisioner( + Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger()); - private static YouTubeAlbumDetail BuildDetail() => new( - 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)]); + return new GetYouTubeAlbumQueryHandler(youTubeMusicService, provisioner, NoOpLogger()); + } - [Fact] - public async Task Handle_EmptyAlbumId_ReturnsValidationError() - { - var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery(string.Empty), TestContext.Current.CancellationToken); + private static YouTubeAlbumDetail BuildDetail() => new( + 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)]); - 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] - public async Task Handle_ServiceSucceeds_MaterializesAlbumAndReturnsDetail() - { - var detail = BuildDetail(); - youTubeMusicService.GetAlbumAsync("album-1", Arg.Any()).Returns(detail); + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } - 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()).Returns(detail); - Assert.False(result.IsError); - Assert.Same(detail, result.Value); - Assert.NotEmpty(await Database.ExternalAlbums.ToListAsync(TestContext.Current.CancellationToken)); - } + var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery("album-1"), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_ServiceFails_ReturnsError() - { - youTubeMusicService.GetAlbumAsync("missing", Arg.Any()) - .Returns(Error.NotFound(description: "Album not found")); + Assert.False(result.IsError); + Assert.Same(detail, result.Value); + Assert.NotEmpty(await Database.ExternalAlbums.ToListAsync(TestContext.Current.CancellationToken)); + } - var result = await CreateHandler().Handle(new GetYouTubeAlbumQuery("missing"), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ServiceFails_ReturnsError() + { + youTubeMusicService.GetAlbumAsync("missing", Arg.Any()) + .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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/YouTube/ResolveYouTubeTrackStreamCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/YouTube/ResolveYouTubeTrackStreamCommandHandlerTests.cs index 3ffbb46..300f64b 100644 --- a/backend/Tests/Musify.Application.Tests/YouTube/ResolveYouTubeTrackStreamCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/YouTube/ResolveYouTubeTrackStreamCommandHandlerTests.cs @@ -7,59 +7,60 @@ using Musify.Application.YouTube.Responses; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.YouTube; - -public sealed class ResolveYouTubeTrackStreamCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.YouTube { - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - private readonly IStreamTicketService ticketService = Substitute.For(); - - private ResolveYouTubeTrackStreamCommandHandler CreateHandler() + public sealed class ResolveYouTubeTrackStreamCommandHandlerTests : HandlerTestBase { - var provisioner = new YouTubeTrackProvisioner( - Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger()); - var issuer = new TrackStreamIssuer( - Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway()); + private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); + private readonly IStreamTicketService ticketService = Substitute.For(); - return new ResolveYouTubeTrackStreamCommandHandler( - Database, youTubeMusicService, provisioner, issuer, NoOpLogger()); - } + private ResolveYouTubeTrackStreamCommandHandler CreateHandler() + { + var provisioner = new YouTubeTrackProvisioner( + Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger()); + var issuer = new TrackStreamIssuer( + Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway()); - [Fact] - public async Task Handle_AlreadyProcessedTrack_IssuesServerStreamWithoutCallingYouTubeForAudio() - { - ticketService.IssueTicket(Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); + return new ResolveYouTubeTrackStreamCommandHandler( + Database, youTubeMusicService, provisioner, issuer, NoOpLogger()); + } - var user = TestEntities.User(1); - var track = TestEntities.ExternalTrack(externalId: "abc123"); - await SeedAsync(user, track); + [Fact] + public async Task Handle_AlreadyProcessedTrack_IssuesServerStreamWithoutCallingYouTubeForAudio() + { + ticketService.IssueTicket(Arg.Any(), Arg.Any()).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); - Assert.Equal(YouTubeStreamMode.Server, result.Value.Mode); - Assert.Equal("token", result.Value.Ticket); - await youTubeMusicService.DidNotReceiveWithAnyArgs().GetAudioStreamAsync(default!, TestContext.Current.CancellationToken); - } + var result = await CreateHandler().Handle(new ResolveYouTubeTrackStreamCommand("abc123", 1), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_UnknownTrack_ProvisionsItAndRecordsListeningHistory() - { - youTubeMusicService.GetAudioStreamAsync("new-video", Arg.Any()) - .Returns(new YouTubeStreamInfo("https://youtube.example/stream", 300)); - youTubeMusicService.GetSongAsync("new-video", Arg.Any()) - .Returns(new YouTubeSongResult("new-video", "Title", "Artist", "Album", 200, "https://img/thumb.jpg", false, [])); - youTubeMusicService.ResolveArtworkUrl(Arg.Any()).Returns(callInfo => callInfo.Arg()); - await SeedAsync(TestEntities.User(42)); + Assert.False(result.IsError); + Assert.Equal(YouTubeStreamMode.Server, result.Value.Mode); + Assert.Equal("token", result.Value.Ticket); + await youTubeMusicService.DidNotReceiveWithAnyArgs().GetAudioStreamAsync(default!, TestContext.Current.CancellationToken); + } - 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()) + .Returns(new YouTubeStreamInfo("https://youtube.example/stream", 300)); + youTubeMusicService.GetSongAsync("new-video", Arg.Any()) + .Returns(new YouTubeSongResult("new-video", "Title", "Artist", "Album", 200, "https://img/thumb.jpg", false, [])); + youTubeMusicService.ResolveArtworkUrl(Arg.Any()).Returns(callInfo => callInfo.Arg()); + await SeedAsync(TestEntities.User(42)); - Assert.False(result.IsError); - Assert.Equal(YouTubeStreamMode.YouTube, result.Value.Mode); - Assert.Equal("https://youtube.example/stream", result.Value.StreamUrl); - Assert.NotNull(result.Value.TrackId); + var result = await CreateHandler().Handle(new ResolveYouTubeTrackStreamCommand("new-video", 42), TestContext.Current.CancellationToken); - var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); - Assert.Equal(42, history.UserId); + Assert.False(result.IsError); + 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); + } } } diff --git a/backend/Tests/Musify.Application.Tests/YouTube/SearchYouTubeAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/YouTube/SearchYouTubeAlbumsQueryHandlerTests.cs index 86290b3..9532847 100644 --- a/backend/Tests/Musify.Application.Tests/YouTube/SearchYouTubeAlbumsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/YouTube/SearchYouTubeAlbumsQueryHandlerTests.cs @@ -4,33 +4,34 @@ using Musify.Application.YouTube; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.YouTube; - -public sealed class SearchYouTubeAlbumsQueryHandlerTests +namespace Musify.Application.Tests.YouTube { - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - - private SearchYouTubeAlbumsQueryHandler CreateHandler() => new(youTubeMusicService); - - [Fact] - public async Task Handle_EmptyQuery_ReturnsValidationErrorWithoutCallingTheService() + public sealed class SearchYouTubeAlbumsQueryHandlerTests { - var result = await CreateHandler().Handle(new SearchYouTubeAlbumsQuery(string.Empty, string.Empty), TestContext.Current.CancellationToken); + private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - await youTubeMusicService.DidNotReceiveWithAnyArgs() - .SearchAlbumsAsync(default!, default!, TestContext.Current.CancellationToken); - } + private SearchYouTubeAlbumsQueryHandler CreateHandler() => new(youTubeMusicService); - [Fact] - public async Task Handle_NonEmptyQuery_DelegatesToTheService() - { - var expected = new YouTubeAlbumSearchResult([], "next-token"); - youTubeMusicService.SearchAlbumsAsync("query", "token", Arg.Any()).Returns(expected); + [Fact] + public async Task Handle_EmptyQuery_ReturnsValidationErrorWithoutCallingTheService() + { + var result = await CreateHandler().Handle(new SearchYouTubeAlbumsQuery(string.Empty, string.Empty), TestContext.Current.CancellationToken); - 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); - Assert.Same(expected, result.Value); + [Fact] + public async Task Handle_NonEmptyQuery_DelegatesToTheService() + { + var expected = new YouTubeAlbumSearchResult([], "next-token"); + youTubeMusicService.SearchAlbumsAsync("query", "token", Arg.Any()).Returns(expected); + + var result = await CreateHandler().Handle(new SearchYouTubeAlbumsQuery("query", "token"), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Same(expected, result.Value); + } } } diff --git a/backend/Tests/Musify.Application.Tests/YouTube/SearchYouTubeTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/YouTube/SearchYouTubeTracksQueryHandlerTests.cs index d6d8aba..73c4495 100644 --- a/backend/Tests/Musify.Application.Tests/YouTube/SearchYouTubeTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/YouTube/SearchYouTubeTracksQueryHandlerTests.cs @@ -4,33 +4,34 @@ using Musify.Application.YouTube; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.YouTube; - -public sealed class SearchYouTubeTracksQueryHandlerTests +namespace Musify.Application.Tests.YouTube { - private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - - private SearchYouTubeTracksQueryHandler CreateHandler() => new(youTubeMusicService); - - [Fact] - public async Task Handle_EmptyQuery_ReturnsValidationErrorWithoutCallingTheService() + public sealed class SearchYouTubeTracksQueryHandlerTests { - var result = await CreateHandler().Handle(new SearchYouTubeTracksQuery(string.Empty, string.Empty), TestContext.Current.CancellationToken); + private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - await youTubeMusicService.DidNotReceiveWithAnyArgs() - .SearchSongsAsync(default!, default!, TestContext.Current.CancellationToken); - } + private SearchYouTubeTracksQueryHandler CreateHandler() => new(youTubeMusicService); - [Fact] - public async Task Handle_NonEmptyQuery_DelegatesToTheService() - { - var expected = new YouTubeSearchResult([], "next-token"); - youTubeMusicService.SearchSongsAsync("query", "token", Arg.Any()).Returns(expected); + [Fact] + public async Task Handle_EmptyQuery_ReturnsValidationErrorWithoutCallingTheService() + { + var result = await CreateHandler().Handle(new SearchYouTubeTracksQuery(string.Empty, string.Empty), TestContext.Current.CancellationToken); - 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); - Assert.Same(expected, result.Value); + [Fact] + public async Task Handle_NonEmptyQuery_DelegatesToTheService() + { + var expected = new YouTubeSearchResult([], "next-token"); + youTubeMusicService.SearchSongsAsync("query", "token", Arg.Any()).Returns(expected); + + var result = await CreateHandler().Handle(new SearchYouTubeTracksQuery("query", "token"), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Same(expected, result.Value); + } } } diff --git a/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackAudioTests.cs b/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackAudioTests.cs index 7b4e0a5..78fde26 100644 --- a/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackAudioTests.cs +++ b/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackAudioTests.cs @@ -1,22 +1,23 @@ using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Domain.Tests.ValueObjects; - -public sealed class TrackAudioTests +namespace Musify.Domain.Tests.ValueObjects { - [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) + public sealed class TrackAudioTests { - 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(expectedFailed, audio.IsFailed); - Assert.Equal(expectedInProgress, audio.IsInProgress); + Assert.Equal(expectedProcessed, audio.IsProcessed); + Assert.Equal(expectedFailed, audio.IsFailed); + Assert.Equal(expectedInProgress, audio.IsInProgress); + } } } diff --git a/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackPicturesTests.cs b/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackPicturesTests.cs index a9b0ca3..9ac53db 100644 --- a/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackPicturesTests.cs +++ b/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackPicturesTests.cs @@ -1,19 +1,20 @@ using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Domain.Tests.ValueObjects; - -public sealed class TrackPicturesTests +namespace Musify.Domain.Tests.ValueObjects { - [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) + public sealed class TrackPicturesTests { - 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); + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/Persistence/AuditableEntityInterceptorTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Persistence/AuditableEntityInterceptorTests.cs index ff74c7c..d4ae5ac 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Persistence/AuditableEntityInterceptorTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Persistence/AuditableEntityInterceptorTests.cs @@ -2,44 +2,45 @@ using Musify.Domain.Entities; using Musify.Infrastructure.Tests.TestSupport; using Xunit; -namespace Musify.Infrastructure.Tests.Persistence; - -[Collection(InfrastructureCollection.Name)] -public sealed class AuditableEntityInterceptorTests(InfrastructureTestFixture fixture) +namespace Musify.Infrastructure.Tests.Persistence { - [Fact] - public async Task SavingChanges_NewEntity_SetsCreatedAtAndUpdatedAtToNow() + [Collection(InfrastructureCollection.Name)] + public sealed class AuditableEntityInterceptorTests(InfrastructureTestFixture fixture) { - await using var database = fixture.CreateDatabase(); - var before = DateTime.UtcNow; + [Fact] + public async Task SavingChanges_NewEntity_SetsCreatedAtAndUpdatedAtToNow() + { + await using var database = fixture.CreateDatabase(); + var before = DateTime.UtcNow; - var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "audit-user", NormalizedName = "AUDIT-USER" }; - await database.Users.AddAsync(user, TestContext.Current.CancellationToken); - await database.SaveChangesAsync(TestContext.Current.CancellationToken); + 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.SaveChangesAsync(TestContext.Current.CancellationToken); - var after = DateTime.UtcNow; + var after = DateTime.UtcNow; - Assert.InRange(user.CreatedAt, before, after); - Assert.InRange(user.UpdatedAt, before, after); - } + Assert.InRange(user.CreatedAt, before, after); + Assert.InRange(user.UpdatedAt, before, after); + } - [Fact] - public async Task SavingChanges_ModifiedEntity_BumpsUpdatedAtButKeepsCreatedAt() - { - await using var database = fixture.CreateDatabase(); + [Fact] + public async Task SavingChanges_ModifiedEntity_BumpsUpdatedAtButKeepsCreatedAt() + { + await using var database = fixture.CreateDatabase(); - var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "audit-user-2", NormalizedName = "AUDIT-USER-2" }; - await database.Users.AddAsync(user, TestContext.Current.CancellationToken); - await database.SaveChangesAsync(TestContext.Current.CancellationToken); - var originalCreatedAt = user.CreatedAt; + 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.SaveChangesAsync(TestContext.Current.CancellationToken); + 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"; - await database.SaveChangesAsync(TestContext.Current.CancellationToken); + user.Name = "audit-user-2-renamed"; + await database.SaveChangesAsync(TestContext.Current.CancellationToken); - Assert.Equal(originalCreatedAt, user.CreatedAt); - Assert.True(user.UpdatedAt > originalCreatedAt, - $"original-created-at={originalCreatedAt:O}, final-updated-at={user.UpdatedAt:O}"); + Assert.Equal(originalCreatedAt, user.CreatedAt); + Assert.True(user.UpdatedAt > originalCreatedAt, + $"original-created-at={originalCreatedAt:O}, final-updated-at={user.UpdatedAt:O}"); + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs index 772da33..6de31d1 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs @@ -4,88 +4,89 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.Tests.TestSupport; using Xunit; -namespace Musify.Infrastructure.Tests.Persistence; - -[Collection(InfrastructureCollection.Name)] -public sealed class DatabaseTests(InfrastructureTestFixture fixture) +namespace Musify.Infrastructure.Tests.Persistence { - [Fact] - public async Task SaveChangesAsync_LocalTrackWithOwnedTypes_RoundTripsThroughPostgres() + [Collection(InfrastructureCollection.Name)] + public sealed class DatabaseTests(InfrastructureTestFixture fixture) { - await using var database = fixture.CreateDatabase(); - - var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "roundtrip-user", NormalizedName = "ROUNDTRIP-USER" }; - await database.Users.AddAsync(user, TestContext.Current.CancellationToken); - - var track = new LocalTrack + [Fact] + public async Task SaveChangesAsync_LocalTrackWithOwnedTypes_RoundTripsThroughPostgres() { - 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 using var database = fixture.CreateDatabase(); + + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "roundtrip-user", NormalizedName = "ROUNDTRIP-USER" }; + await database.Users.AddAsync(user, TestContext.Current.CancellationToken); + + var track = new LocalTrack + { + Title = "Round Trip Track", + NormalizedTitle = "ROUND TRIP TRACK", + DurationSeconds = 123.4, + OwnerUserId = user.Id, + Owner = user, + Pictures = new TrackPictures { OriginalName = "cover.webp", ProcessingStatus = ProcessingStatus.Pending }, + Audio = new TrackAudio { OriginalName = "song.mp3", TranscodeStatus = ProcessingStatus.Pending } + }; + await database.LocalTracks.AddAsync(track, 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(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(() => 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(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(() => 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() - }; } diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/DisabledYouTubeMusicServiceTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/DisabledYouTubeMusicServiceTests.cs index 6ef3286..632b041 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/DisabledYouTubeMusicServiceTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/DisabledYouTubeMusicServiceTests.cs @@ -2,57 +2,58 @@ using ErrorOr; using Musify.Infrastructure.Services; using Xunit; -namespace Musify.Infrastructure.Tests.Services; - -public sealed class DisabledYouTubeMusicServiceTests +namespace Musify.Infrastructure.Tests.Services { - private readonly DisabledYouTubeMusicService service = new(); - - [Fact] - public async Task SearchSongsAsync_ReturnsForbidden() + public sealed class DisabledYouTubeMusicServiceTests { - 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] - public async Task SearchAlbumsAsync_ReturnsForbidden() - { - var result = await service.SearchAlbumsAsync("query", string.Empty, TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } - 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] - public async Task GetAlbumAsync_ReturnsForbidden() - { - var result = await service.GetAlbumAsync("album-1", TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } + [Fact] + public async Task GetAlbumAsync_ReturnsForbidden() + { + var result = await service.GetAlbumAsync("album-1", TestContext.Current.CancellationToken); - [Fact] - public async Task GetAudioStreamAsync_ReturnsForbidden() - { - var result = await service.GetAudioStreamAsync("video-1", TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } + [Fact] + public async Task GetAudioStreamAsync_ReturnsForbidden() + { + var result = await service.GetAudioStreamAsync("video-1", TestContext.Current.CancellationToken); - [Fact] - public async Task GetSongAsync_ReturnsForbidden() - { - var result = await service.GetSongAsync("video-1", TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } + [Fact] + public async Task GetSongAsync_ReturnsForbidden() + { + var result = await service.GetSongAsync("video-1", TestContext.Current.CancellationToken); - [Fact] - public void ResolveArtworkUrl_ReturnsTheUrlUnchanged() - { - var result = service.ResolveArtworkUrl("https://img.example/thumb.jpg"); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } - 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); + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/PictureServiceTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/PictureServiceTests.cs index bf893c8..7b0f086 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/PictureServiceTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/PictureServiceTests.cs @@ -4,54 +4,55 @@ using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using Xunit; -namespace Musify.Infrastructure.Tests.Services; - -public sealed class PictureServiceTests +namespace Musify.Infrastructure.Tests.Services { - private static PictureService CreateService() => new(NullLogger.Instance); - - private static MemoryStream CreateSourceImage(int width, int height) + public sealed class PictureServiceTests { - using var image = new Image(width, height, new Rgba32(255, 0, 0)); - var stream = new MemoryStream(); - image.SaveAsPng(stream); - stream.Position = 0; - return stream; - } + private static PictureService CreateService() => new(NullLogger.Instance); - [Fact] - public async Task ResizePictureAsWebpAsync_LargerThanTarget_ShrinksToFitWithinTheRequestedBox() - { - using var source = CreateSourceImage(800, 600); + private static MemoryStream CreateSourceImage(int width, int height) + { + using var image = new Image(width, height, new Rgba32(255, 0, 0)); + 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); - 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"); - } + await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 200, 200, TestContext.Current.CancellationToken); - [Fact] - public async Task ResizePictureAsWebpAsync_ReturnsAValidWebpStream() - { - using var source = CreateSourceImage(100, 100); + using var result = await Image.LoadAsync(resized, 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"); + } - 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); - Assert.Equal("WEBP", format?.Name, ignoreCase: true); - } + await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 50, 50, TestContext.Current.CancellationToken); - [Fact] - public async Task ResizePictureAsWebpAsync_PreservesAspectRatio() - { - using var source = CreateSourceImage(400, 200); + var format = await Image.DetectFormatAsync(resized, TestContext.Current.CancellationToken); + Assert.Equal("WEBP", format?.Name, ignoreCase: true); + } - 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); - Assert.Equal(100, result.Width); - Assert.Equal(50, result.Height); + await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 100, 100, TestContext.Current.CancellationToken); + + using var result = await Image.LoadAsync(resized, TestContext.Current.CancellationToken); + Assert.Equal(100, result.Width); + Assert.Equal(50, result.Height); + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/PlayListPresetSeederTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/PlayListPresetSeederTests.cs index acd4d9c..6cbcfb3 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/PlayListPresetSeederTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/PlayListPresetSeederTests.cs @@ -7,96 +7,97 @@ using Musify.Infrastructure.Services; using Musify.Infrastructure.Tests.TestSupport; using Xunit; -namespace Musify.Infrastructure.Tests.Services; - -[Collection(InfrastructureCollection.Name)] -public sealed class PlayListPresetSeederTests(InfrastructureTestFixture fixture) +namespace Musify.Infrastructure.Tests.Services { - private static PlayListConfiguration NewConfiguration(bool overwritePresetPictures) => new() + [Collection(InfrastructureCollection.Name)] + public sealed class PlayListPresetSeederTests(InfrastructureTestFixture fixture) { - Routes = new PlayListRoutes { ParentFolders = $"PlayLists-{Guid.NewGuid():N}" }, - SeedPresetPictures = true, - OverwritePresetPictures = overwritePresetPictures - }; - - private (PlayListPresetSeeder Seeder, IStorageService Storage) Build(PlayListConfiguration configuration) - { - var services = new ServiceCollection(); - services.AddSingleton(configuration); - services.AddSingleton(new ApplicationStorageConfiguration { Bucket = InfrastructureTestFixture.S3Bucket }); - services.AddSingleton(_ => new StorageService( - fixture.CreateDatabase(), - fixture.CreateS3Client(), - NullLogger.Instance, - new InfrastructureStorageConfiguration - { - Address = fixture.S3ServiceUrl, - AccessKey = InfrastructureTestFixture.S3AccessKey, - SecretAccessKey = InfrastructureTestFixture.S3SecretKey, - ForcePathStyle = true, - UseHttp = true, - })); - services.AddSingleton(new PictureService(NullLogger.Instance)); - - var provider = services.BuildServiceProvider(); - var seeder = new PlayListPresetSeeder(provider.GetRequiredService(), NullLogger.Instance); - return (seeder, provider.GetRequiredService()); - } - - 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 - }) + private static PlayListConfiguration NewConfiguration(bool overwritePresetPictures) => new() { - var metadata = await storage.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); - Assert.True(metadata is not null, $"expected {key} to have been seeded"); + Routes = new PlayListRoutes { ParentFolders = $"PlayLists-{Guid.NewGuid():N}" }, + SeedPresetPictures = true, + OverwritePresetPictures = overwritePresetPictures + }; + + private (PlayListPresetSeeder Seeder, IStorageService Storage) Build(PlayListConfiguration configuration) + { + var services = new ServiceCollection(); + services.AddSingleton(configuration); + services.AddSingleton(new ApplicationStorageConfiguration { Bucket = InfrastructureTestFixture.S3Bucket }); + services.AddSingleton(_ => new StorageService( + fixture.CreateDatabase(), + fixture.CreateS3Client(), + NullLogger.Instance, + new InfrastructureStorageConfiguration + { + Address = fixture.S3ServiceUrl, + AccessKey = InfrastructureTestFixture.S3AccessKey, + SecretAccessKey = InfrastructureTestFixture.S3SecretKey, + ForcePathStyle = true, + UseHttp = true, + })); + services.AddSingleton(new PictureService(NullLogger.Instance)); + + var provider = services.BuildServiceProvider(); + var seeder = new PlayListPresetSeeder(provider.GetRequiredService(), NullLogger.Instance); + return (seeder, provider.GetRequiredService()); + } + + 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); - } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/SingleFlightCacheTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/SingleFlightCacheTests.cs index da41272..50d7158 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/SingleFlightCacheTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/SingleFlightCacheTests.cs @@ -3,78 +3,79 @@ using Microsoft.Extensions.Caching.Memory; using Musify.Infrastructure.Services; using Xunit; -namespace Musify.Infrastructure.Tests.Services; - -public sealed class SingleFlightCacheTests +namespace Musify.Infrastructure.Tests.Services { - private static SingleFlightCache CreateCache() => new(new MemoryCache(new MemoryCacheOptions())); - - [Fact] - public async Task GetOrCreateAsync_SecondCallWithinDuration_ReturnsCachedValueWithoutCallingFactory() + public sealed class SingleFlightCacheTests { - var cache = CreateCache(); - var calls = 0; - Task> Factory() { calls++; return Task.FromResult>(calls); } + private static SingleFlightCache CreateCache() => new(new MemoryCache(new MemoryCacheOptions())); - var first = await cache.GetOrCreateAsync("key", TimeSpan.FromMinutes(1), Factory); - var second = await cache.GetOrCreateAsync("key", TimeSpan.FromMinutes(1), Factory); - - Assert.Equal(1, first.Value); - Assert.Equal(1, second.Value); - Assert.Equal(1, calls); - } - - [Fact] - public async Task GetOrCreateAsync_ConcurrentCallsForTheSameKey_CollapseIntoASingleFactoryCall() - { - var cache = CreateCache(); - var calls = 0; - var gate = new TaskCompletionSource(); - - async Task> Factory() + [Fact] + public async Task GetOrCreateAsync_SecondCallWithinDuration_ReturnsCachedValueWithoutCallingFactory() { - Interlocked.Increment(ref calls); - await gate.Task; - return 7; + var cache = CreateCache(); + var calls = 0; + Task> Factory() { calls++; return Task.FromResult>(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); - var second = cache.GetOrCreateAsync("concurrent-key", TimeSpan.FromMinutes(1), Factory); - gate.SetResult(); - - var results = await Task.WhenAll(first, second); - - Assert.Equal(1, calls); - Assert.All(results, result => Assert.Equal(7, result.Value)); - } - - [Fact] - public async Task GetOrCreateAsync_FactoryReturnsError_DoesNotCacheIt() - { - var cache = CreateCache(); - var calls = 0; - Task> Factory() + [Fact] + public async Task GetOrCreateAsync_ConcurrentCallsForTheSameKey_CollapseIntoASingleFactoryCall() { - calls++; - return Task.FromResult>(Error.Failure(description: "boom")); + var cache = CreateCache(); + var calls = 0; + var gate = new TaskCompletionSource(); + + async Task> 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); - await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory); + [Fact] + public async Task GetOrCreateAsync_FactoryReturnsError_DoesNotCacheIt() + { + var cache = CreateCache(); + var calls = 0; + Task> Factory() + { + calls++; + return Task.FromResult>(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] - public async Task GetOrCreateAsync_NonPositiveDuration_AlwaysCallsTheFactory() - { - var cache = CreateCache(); - var calls = 0; - Task> Factory() { calls++; return Task.FromResult>(calls); } + Assert.Equal(2, calls); + } - await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory); - await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory); + [Fact] + public async Task GetOrCreateAsync_NonPositiveDuration_AlwaysCallsTheFactory() + { + var cache = CreateCache(); + var calls = 0; + Task> Factory() { calls++; return Task.FromResult>(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); + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs index 9b606e4..5cc045c 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs @@ -8,144 +8,145 @@ using Musify.Infrastructure.Services; using Musify.Infrastructure.Tests.TestSupport; using Xunit; -namespace Musify.Infrastructure.Tests.Services; - -[Collection(InfrastructureCollection.Name)] -public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAsyncLifetime +namespace Musify.Infrastructure.Tests.Services { - private Database database = null!; - private StorageService service = null!; - - public ValueTask InitializeAsync() + [Collection(InfrastructureCollection.Name)] + public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAsyncLifetime { - database = fixture.CreateDatabase(); - var storageConfiguration = new InfrastructureStorageConfiguration + private Database database = null!; + private StorageService service = null!; + + public ValueTask InitializeAsync() { - Address = fixture.S3ServiceUrl, - AccessKey = InfrastructureTestFixture.S3AccessKey, - SecretAccessKey = InfrastructureTestFixture.S3SecretKey, - ForcePathStyle = true, - UseHttp = true, - }; - service = new StorageService(database, fixture.CreateS3Client(), NullLogger.Instance, storageConfiguration); - return ValueTask.CompletedTask; - } + database = fixture.CreateDatabase(); + var storageConfiguration = new InfrastructureStorageConfiguration + { + Address = fixture.S3ServiceUrl, + AccessKey = InfrastructureTestFixture.S3AccessKey, + SecretAccessKey = InfrastructureTestFixture.S3SecretKey, + ForcePathStyle = true, + UseHttp = true, + }; + service = new StorageService(database, fixture.CreateS3Client(), NullLogger.Instance, storageConfiguration); + return ValueTask.CompletedTask; + } - public ValueTask DisposeAsync() => database.DisposeAsync(); + public ValueTask DisposeAsync() => database.DisposeAsync(); - [Fact] - 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(); - 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(); - 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) + [Fact] + public async Task UploadThenGetFile_RoundTripsTheSameBytes() { - // 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); + var key = $"tests/{Guid.NewGuid():N}/round-trip.txt"; + var content = "hello seaweedfs"u8.ToArray(); - 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); + 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(); + 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(); + 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); + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/StreamTicketServiceTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/StreamTicketServiceTests.cs index b701dc9..f233fd3 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/StreamTicketServiceTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/StreamTicketServiceTests.cs @@ -5,67 +5,68 @@ using Musify.Infrastructure.Configuration; using Musify.Infrastructure.Services; using Xunit; -namespace Musify.Infrastructure.Tests.Services; - -public sealed class StreamTicketServiceTests : IDisposable +namespace Musify.Infrastructure.Tests.Services { - private readonly string privateKeyPath = Path.GetTempFileName(); - private readonly RSA rsa = RSA.Create(2048); - - public StreamTicketServiceTests() => File.WriteAllText(privateKeyPath, rsa.ExportPkcs8PrivateKeyPem()); - - public void Dispose() + public sealed class StreamTicketServiceTests : IDisposable { - rsa.Dispose(); - File.Delete(privateKeyPath); - } + private readonly string privateKeyPath = Path.GetTempFileName(); + private readonly RSA rsa = RSA.Create(2048); - private StreamTicketService CreateService(int ticketTtlSeconds = 3600) => new(new StreamTicketConfiguration - { - PrivateKeyPath = privateKeyPath, - Audience = "media-gateway", - Issuer = "musify-webapi", - TicketTtlSeconds = ticketTtlSeconds - }); + public StreamTicketServiceTests() => File.WriteAllText(privateKeyPath, rsa.ExportPkcs8PrivateKeyPem()); - [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 + public void Dispose() { - ValidIssuer = "musify-webapi", - ValidAudience = "media-gateway", - ValidateIssuer = true, - ValidateAudience = true, - ValidateLifetime = true, - IssuerSigningKey = new RsaSecurityKey(verifyingRsa), + rsa.Dispose(); + File.Delete(privateKeyPath); + } + + private StreamTicketService CreateService(int ticketTtlSeconds = 3600) => new(new StreamTicketConfiguration + { + 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); + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs b/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs index ae27607..940dfd3 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs @@ -10,108 +10,109 @@ using DotNet.Testcontainers.Builders; using DotNet.Testcontainers.Containers; using Xunit; -namespace Musify.Infrastructure.Tests.TestSupport; - -/// -/// One Postgres container and one SeaweedFS container (same image and startup -/// command as deploy/compose.yml), started once for every test in the -/// and torn down when the run finishes. -/// EF Core migrations run once here too, against the real Database -/// DbContext, so a broken migration fails the whole run immediately. -/// -public sealed class InfrastructureTestFixture : IAsyncLifetime +namespace Musify.Infrastructure.Tests.TestSupport { - public const string S3AccessKey = "musify-test"; - public const string S3SecretKey = "musify-test-secret"; - public const string S3Bucket = "musify-test-bucket"; - - private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine") - .WithDatabase("musify_test") - .WithUsername("postgres") - .WithPassword("postgres") - .Build(); - - private readonly IContainer seaweedFs = new ContainerBuilder("chrislusf/seaweedfs:latest") - .WithEntrypoint("/bin/sh", "-c") - .WithCommand(BuildSeaweedFsStartupScript()) - .WithPortBinding(9333, assignRandomHostPort: true) - .WithPortBinding(8333, assignRandomHostPort: true) - .WithWaitStrategy(Wait.ForUnixContainer() - .UntilHttpRequestIsSucceeded(request => request.ForPort(9333).ForPath("/cluster/healthz"))) - .Build(); - - public string ConnectionString => postgres.GetConnectionString(); - - public string S3ServiceUrl => $"http://{seaweedFs.Hostname}:{seaweedFs.GetMappedPublicPort(8333)}"; - - public async ValueTask InitializeAsync() + /// + /// One Postgres container and one SeaweedFS container (same image and startup + /// command as deploy/compose.yml), started once for every test in the + /// and torn down when the run finishes. + /// EF Core migrations run once here too, against the real Database + /// DbContext, so a broken migration fails the whole run immediately. + /// + public sealed class InfrastructureTestFixture : IAsyncLifetime { - 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 }); - 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(); - } - - /// A fresh, DI-wired , so AuditableEntityInterceptor - /// (attached in AddDatabase's DI registration, not in the class itself) is active — - /// same wiring the real app uses, not a bare new Database(...). - public Database CreateDatabase() - { - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection([new("Database:ConnectionString", ConnectionString)]) + private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine") + .WithDatabase("musify_test") + .WithUsername("postgres") + .WithPassword("postgres") .Build(); - var services = new ServiceCollection(); - services.AddDatabase(configuration); - return services.BuildServiceProvider().GetRequiredService(); - } + 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(); - /// An client configured exactly like - /// ServicesDependencyInjection.AddStorageService, pointed at the SeaweedFS container. - public IAmazonS3 CreateS3Client() => - new AmazonS3Client(S3AccessKey, S3SecretKey, new AmazonS3Config - { - ServiceURL = S3ServiceUrl, - ForcePathStyle = true, - UseHttp = true, - }); + public string ConnectionString => postgres.GetConnectionString(); - private static string BuildSeaweedFsStartupScript() - { - var identity = JsonSerializer.Serialize(new + public string S3ServiceUrl => $"http://{seaweedFs.Hostname}:{seaweedFs.GetMappedPublicPort(8333)}"; + + 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(); + } + + /// A fresh, DI-wired , so AuditableEntityInterceptor + /// (attached in AddDatabase's DI registration, not in the class itself) is active — + /// same wiring the real app uses, not a bare new Database(...). + public Database CreateDatabase() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection([new("Database:ConnectionString", ConnectionString)]) + .Build(); + + var services = new ServiceCollection(); + services.AddDatabase(configuration); + return services.BuildServiceProvider().GetRequiredService(); + } + + /// An client configured exactly like + /// ServicesDependencyInjection.AddStorageService, pointed at the SeaweedFS container. + public IAmazonS3 CreateS3Client() => + new AmazonS3Client(S3AccessKey, S3SecretKey, new AmazonS3Config { - new - { - name = "musify-test", - credentials = new[] { new { accessKey = S3AccessKey, secretKey = S3SecretKey } }, - actions = new[] { "Admin", "Read", "Write" } - } - } - }); + ServiceURL = S3ServiceUrl, + ForcePathStyle = true, + UseHttp = true, + }); - 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 - """; + private static string BuildSeaweedFsStartupScript() + { + var identity = JsonSerializer.Serialize(new + { + identities = new[] + { + new + { + name = "musify-test", + credentials = new[] { new { accessKey = S3AccessKey, secretKey = S3SecretKey } }, + actions = new[] { "Admin", "Read", "Write" } + } + } + }); + + return $""" + cat > /tmp/s3.conf <<'JSON' + {identity} + JSON + exec weed server -ip.bind=0.0.0.0 -dir=/data -master.volumeSizeLimitMB=1024 -volume.max=0 -filer -s3 -s3.config=/tmp/s3.conf + """; + } + } + + [CollectionDefinition(Name)] + public sealed class InfrastructureCollection : ICollectionFixture + { + public const string Name = "Infrastructure"; } } - -[CollectionDefinition(Name)] -public sealed class InfrastructureCollection : ICollectionFixture -{ - public const string Name = "Infrastructure"; -} diff --git a/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs b/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs index 1f95b55..d907ed4 100644 --- a/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs +++ b/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs @@ -6,141 +6,142 @@ using Musify.StreamingGateway.Authentication; using Musify.StreamingGateway.Configuration; using Xunit; -namespace Musify.StreamingGateway.Tests.Authentication; - -public sealed class TicketValidatorTests : IDisposable +namespace Musify.StreamingGateway.Tests.Authentication { - private const string Issuer = "musify-webapi"; - private const string Audience = "media-gateway"; - - private readonly string publicKeyPath = Path.GetTempFileName(); - private readonly RSA rsa = RSA.Create(2048); - private readonly JsonWebTokenHandler tokenHandler = new(); - - public TicketValidatorTests() => File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem()); - - public void Dispose() + public sealed class TicketValidatorTests : IDisposable { - rsa.Dispose(); - File.Delete(publicKeyPath); - } + private const string Issuer = "musify-webapi"; + private const string Audience = "media-gateway"; - private TicketValidator CreateValidator() => new(Options.Create(new StreamTicketValidationOptions - { - PublicKeyPath = publicKeyPath, - Audience = Audience, - Issuer = Issuer - })); + private readonly string publicKeyPath = Path.GetTempFileName(); + private readonly RSA rsa = RSA.Create(2048); + private readonly JsonWebTokenHandler tokenHandler = new(); - private string IssueToken(string prefix = "Tracks/ProcessedAudios/abc/", string issuer = Issuer, string audience = Audience, - DateTime? expires = null, DateTime? notBefore = null) - { - var descriptor = new SecurityTokenDescriptor + public TicketValidatorTests() => File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem()); + + public void Dispose() { - Issuer = issuer, - Audience = audience, - NotBefore = notBefore ?? DateTime.UtcNow.AddMinutes(-1), - Expires = expires ?? DateTime.UtcNow.AddMinutes(5), - Claims = new Dictionary { ["prefix"] = prefix }, - SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256), - }; - return tokenHandler.CreateToken(descriptor); - } + rsa.Dispose(); + File.Delete(publicKeyPath); + } - [Fact] - public async Task ValidateTicketAsync_ValidToken_ReturnsThePrefixClaim() - { - var token = IssueToken(prefix: "Tracks/ProcessedAudios/xyz/"); - - var prefix = await CreateValidator().ValidateTicketAsync(token); - - Assert.Equal("Tracks/ProcessedAudios/xyz/", prefix); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public async Task ValidateTicketAsync_BlankToken_ReturnsEmptyString(string? token) - { - var prefix = await CreateValidator().ValidateTicketAsync(token); - - Assert.Equal(string.Empty, prefix); - } - - [Fact] - public async Task ValidateTicketAsync_MalformedToken_ReturnsEmptyString() - { - var prefix = await CreateValidator().ValidateTicketAsync("not-a-real-jwt"); - - Assert.Equal(string.Empty, prefix); - } - - [Fact] - public async Task ValidateTicketAsync_ExpiredToken_ReturnsEmptyString() - { - var token = IssueToken(expires: DateTime.UtcNow.AddMinutes(-40), notBefore: DateTime.UtcNow.AddMinutes(-50)); - - var prefix = await CreateValidator().ValidateTicketAsync(token); - - Assert.Equal(string.Empty, prefix); - } - - [Fact] - public async Task ValidateTicketAsync_WrongIssuer_ReturnsEmptyString() - { - var token = IssueToken(issuer: "someone-else"); - - var prefix = await CreateValidator().ValidateTicketAsync(token); - - Assert.Equal(string.Empty, prefix); - } - - [Fact] - public async Task ValidateTicketAsync_WrongAudience_ReturnsEmptyString() - { - var token = IssueToken(audience: "someone-else"); - - var prefix = await CreateValidator().ValidateTicketAsync(token); - - Assert.Equal(string.Empty, prefix); - } - - [Fact] - public async Task ValidateTicketAsync_SignedWithADifferentKey_ReturnsEmptyString() - { - using var otherKey = RSA.Create(2048); - var descriptor = new SecurityTokenDescriptor + private TicketValidator CreateValidator() => new(Options.Create(new StreamTicketValidationOptions { - Issuer = Issuer, + PublicKeyPath = publicKeyPath, Audience = Audience, - NotBefore = DateTime.UtcNow.AddMinutes(-1), - Expires = DateTime.UtcNow.AddMinutes(5), - Claims = new Dictionary { ["prefix"] = "Tracks/ProcessedAudios/abc/" }, - SigningCredentials = new SigningCredentials(new RsaSecurityKey(otherKey), SecurityAlgorithms.RsaSha256), - }; - var token = tokenHandler.CreateToken(descriptor); + Issuer = Issuer + })); - var prefix = await CreateValidator().ValidateTicketAsync(token); - - Assert.Equal(string.Empty, prefix); - } - - [Fact] - public async Task ValidateTicketAsync_MissingPrefixClaim_ReturnsEmptyString() - { - var descriptor = new SecurityTokenDescriptor + private string IssueToken(string prefix = "Tracks/ProcessedAudios/abc/", string issuer = Issuer, string audience = Audience, + DateTime? expires = null, DateTime? notBefore = null) { - 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 descriptor = new SecurityTokenDescriptor + { + Issuer = issuer, + Audience = audience, + NotBefore = notBefore ?? DateTime.UtcNow.AddMinutes(-1), + Expires = expires ?? DateTime.UtcNow.AddMinutes(5), + Claims = new Dictionary { ["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 { ["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); + } } } diff --git a/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs b/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs index 8585975..00bebdc 100644 --- a/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs +++ b/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs @@ -9,145 +9,146 @@ using Musify.StreamingGateway.Configuration; using Musify.StreamingGateway.Middleware; using Xunit; -namespace Musify.StreamingGateway.Tests.Middleware; - -public sealed class TicketValidationMiddlewareTests : IDisposable +namespace Musify.StreamingGateway.Tests.Middleware { - private const string Prefix = "Tracks/ProcessedAudios/abc"; - - private readonly string publicKeyPath = Path.GetTempFileName(); - private readonly RSA rsa = RSA.Create(2048); - private readonly StreamTicketValidationOptions options; - - public TicketValidationMiddlewareTests() + public sealed class TicketValidationMiddlewareTests : IDisposable { - File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem()); - options = new StreamTicketValidationOptions + private const string Prefix = "Tracks/ProcessedAudios/abc"; + + private readonly string publicKeyPath = Path.GetTempFileName(); + private readonly RSA rsa = RSA.Create(2048); + private readonly StreamTicketValidationOptions options; + + public TicketValidationMiddlewareTests() { - PublicKeyPath = publicKeyPath, - Audience = "media-gateway", - Issuer = "musify-webapi", - }; - } + File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem()); + options = new StreamTicketValidationOptions + { + PublicKeyPath = publicKeyPath, + Audience = "media-gateway", + Issuer = "musify-webapi", + }; + } - public void Dispose() - { - rsa.Dispose(); - File.Delete(publicKeyPath); - } + public void Dispose() + { + rsa.Dispose(); + File.Delete(publicKeyPath); + } - private string IssueValidToken(string prefix = Prefix) => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor - { - Issuer = options.Issuer, - Audience = options.Audience, - NotBefore = DateTime.UtcNow.AddMinutes(-1), - Expires = DateTime.UtcNow.AddMinutes(5), - Claims = new Dictionary { ["prefix"] = prefix }, - SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256), - }); + private string IssueValidToken(string prefix = Prefix) => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor + { + Issuer = options.Issuer, + Audience = options.Audience, + NotBefore = DateTime.UtcNow.AddMinutes(-1), + Expires = DateTime.UtcNow.AddMinutes(5), + Claims = new Dictionary { ["prefix"] = prefix }, + SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256), + }); - private (TicketValidationMiddleware Middleware, Func WasNextCalled) CreateMiddleware() - { - var nextCalled = false; - RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; }; - var validator = new TicketValidator(Options.Create(options)); - var middleware = new TicketValidationMiddleware(next, validator, Options.Create(options), NullLogger.Instance); - return (middleware, () => nextCalled); - } + private (TicketValidationMiddleware Middleware, Func WasNextCalled) CreateMiddleware() + { + var nextCalled = false; + RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; }; + var validator = new TicketValidator(Options.Create(options)); + var middleware = new TicketValidationMiddleware(next, validator, Options.Create(options), NullLogger.Instance); + return (middleware, () => nextCalled); + } - [Fact] - public async Task InvokeAsync_PathOutsideMediaPrefix_PassesThroughWithoutValidatingATicket() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = "/health"; + [Fact] + public async Task InvokeAsync_PathOutsideMediaPrefix_PassesThroughWithoutValidatingATicket() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = "/health"; - await middleware.InvokeAsync(context); + await middleware.InvokeAsync(context); - Assert.True(wasNextCalled()); - Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); - } + Assert.True(wasNextCalled()); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + } - [Fact] - public async Task InvokeAsync_NoTicket_ReturnsUnauthorized() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = $"/media/{Prefix}/audio.m4a"; + [Fact] + public async Task InvokeAsync_NoTicket_ReturnsUnauthorized() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; - await middleware.InvokeAsync(context); + await middleware.InvokeAsync(context); - Assert.False(wasNextCalled()); - Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); - } + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + } - [Fact] - public async Task InvokeAsync_ValidTicketAsQueryParameter_ForTheRequestedObject_PassesThroughAndStripsTheTicket() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = $"/media/{Prefix}/audio.m4a"; - context.Request.QueryString = new QueryString($"?t={IssueValidToken()}&keep=1"); + [Fact] + public async Task InvokeAsync_ValidTicketAsQueryParameter_ForTheRequestedObject_PassesThroughAndStripsTheTicket() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken()}&keep=1"); - await middleware.InvokeAsync(context); + await middleware.InvokeAsync(context); - Assert.True(wasNextCalled()); - Assert.Equal("keep=1", context.Request.QueryString.Value?.TrimStart('?')); - } + Assert.True(wasNextCalled()); + Assert.Equal("keep=1", context.Request.QueryString.Value?.TrimStart('?')); + } - [Fact] - public async Task InvokeAsync_ValidTicketAsHeader_PassesThrough() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = $"/media/{Prefix}/audio.m4a"; - context.Request.Headers["X-Stream-Ticket"] = IssueValidToken(); + [Fact] + public async Task InvokeAsync_ValidTicketAsHeader_PassesThrough() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.Headers["X-Stream-Ticket"] = IssueValidToken(); - await middleware.InvokeAsync(context); + await middleware.InvokeAsync(context); - Assert.True(wasNextCalled()); - } + Assert.True(wasNextCalled()); + } - [Fact] - public async Task InvokeAsync_ObjectKeyOutsideAuthorizedPrefix_ReturnsForbidden() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = "/media/Tracks/ProcessedAudios/someone-elses-track/audio.m4a"; - context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); + [Fact] + public async Task InvokeAsync_ObjectKeyOutsideAuthorizedPrefix_ReturnsForbidden() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = "/media/Tracks/ProcessedAudios/someone-elses-track/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); - await middleware.InvokeAsync(context); + await middleware.InvokeAsync(context); - Assert.False(wasNextCalled()); - Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode); - } + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode); + } - [Theory] - [InlineData($"/media/{Prefix}/../../etc/passwd")] - [InlineData($"/media/{Prefix}/./audio.m4a")] - public async Task InvokeAsync_PathTraversalSegment_ReturnsBadRequest(string path) - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = path; - context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); + [Theory] + [InlineData($"/media/{Prefix}/../../etc/passwd")] + [InlineData($"/media/{Prefix}/./audio.m4a")] + public async Task InvokeAsync_PathTraversalSegment_ReturnsBadRequest(string path) + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = path; + context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); - await middleware.InvokeAsync(context); + await middleware.InvokeAsync(context); - Assert.False(wasNextCalled()); - Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); - } + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); + } - [Fact] - public async Task InvokeAsync_TicketPrefixExactlyMatchesTheRequestedObject_PassesThrough() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = $"/media/{Prefix}"; - context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); + [Fact] + public async Task InvokeAsync_TicketPrefixExactlyMatchesTheRequestedObject_PassesThrough() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); - await middleware.InvokeAsync(context); + await middleware.InvokeAsync(context); - Assert.True(wasNextCalled()); + Assert.True(wasNextCalled()); + } } }