From d839a727008bf85d20ce35014006488120424245 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Fri, 25 Sep 2026 01:14:59 +0200 Subject: [PATCH] Add track status filter and counts, album track ids and bulk track deletion GET /tracks/users/{userId} accepts repeated status values and returns the albums each track belongs to. GET /tracks/users/{userId}/status-counts, GET /albums/{id}/tracks/ids and DELETE /tracks with a list of track ids are new. --- .../Albums/GetAlbumTrackIds.cs | 35 +++ .../Albums/Responses/AlbumTrackIdsResponse.cs | 4 + .../Musify.Application/Tracks/DeleteTracks.cs | 60 +++++ .../Tracks/GetTrackStatusCounts.cs | 35 +++ .../Tracks/GetTracksByUserId.cs | 18 +- .../Tracks/Responses/DeleteTracksResponse.cs | 6 + .../Tracks/Responses/TrackProjections.cs | 20 ++ .../Responses/TrackStatusCountsResponse.cs | 9 + .../Responses/UserTrackApplicationResponse.cs | 20 ++ .../Tracks/DeleteTracksRequest.cs | 4 + .../Musify.Api/Endpoints/AlbumEndpoints.cs | 19 ++ .../Musify.Api/Endpoints/TrackEndpoints.cs | 52 ++++- .../Tracks/DeleteTracksRequestValidator.cs | 21 ++ .../Endpoints/TrackLibraryEndpointsTests.cs | 220 ++++++++++++++++++ .../GetAlbumTrackIdsQueryHandlerTests.cs | 53 +++++ .../Tracks/DeleteTracksCommandHandlerTests.cs | 81 +++++++ .../GetTrackStatusCountsQueryHandlerTests.cs | 45 ++++ .../GetTracksByUserIdStatusAndAlbumsTests.cs | 79 +++++++ 18 files changed, 773 insertions(+), 8 deletions(-) create mode 100644 backend/Core/Musify.Application/Albums/GetAlbumTrackIds.cs create mode 100644 backend/Core/Musify.Application/Albums/Responses/AlbumTrackIdsResponse.cs create mode 100644 backend/Core/Musify.Application/Tracks/DeleteTracks.cs create mode 100644 backend/Core/Musify.Application/Tracks/GetTrackStatusCounts.cs create mode 100644 backend/Core/Musify.Application/Tracks/Responses/DeleteTracksResponse.cs create mode 100644 backend/Core/Musify.Application/Tracks/Responses/TrackStatusCountsResponse.cs create mode 100644 backend/Core/Musify.Application/Tracks/Responses/UserTrackApplicationResponse.cs create mode 100644 backend/Hosts/Musify.Api/DataTransferObjects/Tracks/DeleteTracksRequest.cs create mode 100644 backend/Hosts/Musify.Api/Validators/Tracks/DeleteTracksRequestValidator.cs create mode 100644 backend/Tests/Musify.Api.Tests/Endpoints/TrackLibraryEndpointsTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Albums/GetAlbumTrackIdsQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Tracks/DeleteTracksCommandHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Tracks/GetTrackStatusCountsQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdStatusAndAlbumsTests.cs diff --git a/backend/Core/Musify.Application/Albums/GetAlbumTrackIds.cs b/backend/Core/Musify.Application/Albums/GetAlbumTrackIds.cs new file mode 100644 index 0000000..3d64ea0 --- /dev/null +++ b/backend/Core/Musify.Application/Albums/GetAlbumTrackIds.cs @@ -0,0 +1,35 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Albums.Responses; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Albums +{ + public record GetAlbumTrackIdsQuery(long UserId, Guid AlbumId) + : IQuery>; + + public class GetAlbumTrackIdsQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetAlbumTrackIdsQuery request, CancellationToken cancellationToken) + { + var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; + + var trackIds = await database.AlbumHasTracks + .AsNoTracking() + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId + && albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) + .OrderBy(albumTrack => albumTrack.TrackNumber) + .ThenBy(albumTrack => albumTrack.Id) + .Select(albumTrack => albumTrack.TrackId) + .ToListAsync(cancellationToken); + + return new AlbumTrackIdsResponse(trackIds); + } + } +} diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumTrackIdsResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumTrackIdsResponse.cs new file mode 100644 index 0000000..43c169c --- /dev/null +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumTrackIdsResponse.cs @@ -0,0 +1,4 @@ +namespace Musify.Application.Albums.Responses +{ + public record AlbumTrackIdsResponse(IReadOnlyList TrackIds); +} diff --git a/backend/Core/Musify.Application/Tracks/DeleteTracks.cs b/backend/Core/Musify.Application/Tracks/DeleteTracks.cs new file mode 100644 index 0000000..46e4b23 --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/DeleteTracks.cs @@ -0,0 +1,60 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Application.Shared; +using Musify.Application.Tracks.Responses; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Tracks +{ + public record DeleteTracksCommand(long UserId, IReadOnlyList TrackIds) + : ICommand>; + + public class DeleteTracksCommandHandler( + IDatabase database, + IEventBus eventBus, + ILogger logger) + : ICommandHandler> + { + public async ValueTask> Handle(DeleteTracksCommand request, CancellationToken cancellationToken) + { + var requestedIds = request.TrackIds.Distinct().ToList(); + + var tracks = await database.Tracks + .Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) + .ToListAsync(cancellationToken); + + var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId); + if (foreign != null) + return AppErrors.Forbidden("Track", foreign.Id); + + var deleted = new List(); + var failed = new List(); + + foreach (var trackId in requestedIds) + { + var track = tracks.SingleOrDefault(candidate => candidate.Id == trackId); + if (track == null || track.Audio.IsInProgress) + { + failed.Add(trackId); + continue; + } + + track.LifeCycleStatus = LifeCycleStatus.Removing; + await eventBus.PublishAsync(new DeleteTrackEvent(track.Id, request.UserId), cancellationToken); + deleted.Add(trackId); + } + + if (deleted.Count > 0) + { + await database.SaveChangesAsync(cancellationToken); + logger.LogInformation("Marked {Count} tracks of user {UserId} as removing", deleted.Count, request.UserId); + } + + return new DeleteTracksResponse(deleted, failed); + } + } +} diff --git a/backend/Core/Musify.Application/Tracks/GetTrackStatusCounts.cs b/backend/Core/Musify.Application/Tracks/GetTrackStatusCounts.cs new file mode 100644 index 0000000..217893e --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/GetTrackStatusCounts.cs @@ -0,0 +1,35 @@ +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Tracks.Responses; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Tracks +{ + public record GetTrackStatusCountsQuery(long UserId) + : IQuery; + + public class GetTrackStatusCountsQueryHandler(IDatabase database) + : IQueryHandler + { + public async ValueTask Handle(GetTrackStatusCountsQuery request, CancellationToken cancellationToken) + { + var counts = await database.Tracks + .AsNoTracking() + .Active() + .Where(track => track.OwnerUserId == request.UserId) + .GroupBy(track => track.Audio.TranscodeStatus) + .Select(group => new { Status = group.Key, Count = group.Count() }) + .ToListAsync(cancellationToken); + + int CountOf(ProcessingStatus status) => counts.Where(c => c.Status == status).Sum(c => c.Count); + + return new TrackStatusCountsResponse( + counts.Sum(c => c.Count), + CountOf(ProcessingStatus.Pending), + CountOf(ProcessingStatus.Processing), + CountOf(ProcessingStatus.Completed), + CountOf(ProcessingStatus.Failed)); + } + } +} diff --git a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs index 43b799d..78f29a0 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; +using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks { @@ -11,13 +12,14 @@ namespace Musify.Application.Tracks long UserId, string? Name, int PageNumber, - int PageSize) - : IQuery>>; + int PageSize, + IReadOnlyList? Statuses = null) + : IQuery>>; public class GetTracksByUserIdQueryHandler(IDatabase database) - : IQueryHandler>> + : IQueryHandler>> { - public async ValueTask>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken) + public async ValueTask>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken) { var tracksQuery = database.Tracks .AsNoTracking() @@ -30,10 +32,16 @@ namespace Musify.Application.Tracks tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName)); } + if (request.Statuses is { Count: > 0 } statuses) + { + var statusList = statuses.Distinct().ToList(); + tracksQuery = tracksQuery.Where(track => statusList.Contains(track.Audio.TranscodeStatus)); + } + return await tracksQuery .OrderByDescending(track => track.CreatedAt) .ThenBy(track => track.Id) - .SelectResponse() + .SelectUserResponse() .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/Tracks/Responses/DeleteTracksResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/DeleteTracksResponse.cs new file mode 100644 index 0000000..caf45c7 --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/Responses/DeleteTracksResponse.cs @@ -0,0 +1,6 @@ +namespace Musify.Application.Tracks.Responses +{ + public record DeleteTracksResponse( + IReadOnlyList DeletedTrackIds, + IReadOnlyList FailedTrackIds); +} diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs index df228d3..e4dc924 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs @@ -21,5 +21,25 @@ namespace Musify.Application.Tracks.Responses track.OwnerUserId, track.Tags.Select(tag => tag.Tag).ToList(), track.IsExplicit)); + + public static IQueryable SelectUserResponse(this IQueryable query) => + query.Select(track => new UserTrackApplicationResponse( + track.Id, + track.Title, + track.Owner.Name, + track.Audio.TranscodeStatus, + track.DurationSeconds, + track.ListeningHistories.Count(listen => listen.IsCounted), + track.CreatedAt, + track.UpdatedAt, + track.OwnerUserId, + track.Tags.Select(tag => tag.Tag).ToList(), + track.IsExplicit, + track.AlbumTracks + .Where(albumTrack => albumTrack.Album.LifeCycleStatus == LifeCycleStatus.Active) + .OrderBy(albumTrack => albumTrack.AddedAt) + .ThenBy(albumTrack => albumTrack.AlbumId) + .Select(albumTrack => new TrackAlbumReference(albumTrack.AlbumId, albumTrack.Album.Title)) + .ToList())); } } diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackStatusCountsResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackStatusCountsResponse.cs new file mode 100644 index 0000000..97a1f12 --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackStatusCountsResponse.cs @@ -0,0 +1,9 @@ +namespace Musify.Application.Tracks.Responses +{ + public record TrackStatusCountsResponse( + int Total, + int Pending, + int Processing, + int Completed, + int Failed); +} diff --git a/backend/Core/Musify.Application/Tracks/Responses/UserTrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/UserTrackApplicationResponse.cs new file mode 100644 index 0000000..2fde813 --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/Responses/UserTrackApplicationResponse.cs @@ -0,0 +1,20 @@ +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Tracks.Responses +{ + public record TrackAlbumReference(Guid Id, string Title); + + public record UserTrackApplicationResponse( + Guid Id, + string Title, + string? Artist, + ProcessingStatus AudioStatus, + double Duration, + int ListensCount, + DateTime CreatedAt, + DateTime UpdatedAt, + long OwnerUserId, + IReadOnlyCollection Tags, + bool IsExplicit, + IReadOnlyList Albums); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/DeleteTracksRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/DeleteTracksRequest.cs new file mode 100644 index 0000000..36d6dc2 --- /dev/null +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/DeleteTracksRequest.cs @@ -0,0 +1,4 @@ +namespace Musify.Api.DataTransferObjects.Tracks +{ + public record DeleteTracksRequest(IReadOnlyList TrackIds); +} diff --git a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs index 275f238..c56d647 100644 --- a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs @@ -57,6 +57,15 @@ namespace Musify.Api.Endpoints .Produces>() .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:guid}/tracks/ids", GetAlbumTrackIds) + .WithName("GetAlbumTrackIds") + .WithSummary("Get the ids of every track in one of your albums, ordered by track number") + .RequireAuthorization() + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:guid}/cover", GetAlbumCover) .WithName("GetAlbumCover") .WithSummary("Get an album cover image") @@ -195,6 +204,16 @@ namespace Musify.Api.Endpoints return result.ToHttpResult(); } + private static async Task GetAlbumTrackIds( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetAlbumTrackIdsQuery(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } + private static Task GetAlbumCover( IMediator mediator, IStorageService storageService, diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index 7a15b54..4d2561a 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -1,4 +1,5 @@ using Mediator; +using Microsoft.AspNetCore.Mvc; using Musify.Api.Authentication; using Musify.Api.DataTransferObjects.Tracks; using Musify.Api.Extensions; @@ -9,6 +10,7 @@ using Musify.Application.Pictures; using Musify.Application.Shared; using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; +using Musify.Domain.ValueObjects; namespace Musify.Api.Endpoints { @@ -57,8 +59,16 @@ namespace Musify.Api.Endpoints group.MapGet("/users/{userId:long}", GetTracksByUserId) .AddEndpointFilter>() .WithName("GetTracksByUserId") - .WithSummary("Get paginated tracks for a user") - .Produces>(); + .WithSummary("Get paginated tracks for a user, optionally filtered by name and audio processing status. Each track lists the albums it belongs to") + .Produces>(); + + group.MapGet("/users/{userId:long}/status-counts", GetTrackStatusCounts) + .WithName("GetTrackStatusCounts") + .WithSummary("Get how many of your tracks are in each audio processing status") + .RequireAuthorization() + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); group.MapPost("/", CreateTrack) .WithName("CreateTrack") @@ -81,6 +91,16 @@ namespace Musify.Api.Endpoints .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict); + group.MapDelete("/", DeleteTracks) + .WithName("DeleteTracks") + .WithSummary("Delete several of your tracks at once. Tracks that are missing or still processing are returned as failed") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); + group.MapPost("/upload-urls", RequestTrackUploadUrls) .WithName("RequestTrackUploadUrls") .WithSummary("Request pre-signed URLs to upload track picture and audio") @@ -154,9 +174,35 @@ namespace Musify.Api.Endpoints long userId, string? name, [AsParameters] PageQuery page, + CancellationToken cancellationToken, + ProcessingStatus[]? status = null) + { + var result = await mediator.Send( + new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize, status), + cancellationToken); + return result.ToHttpResult(); + } + + private static async Task GetTrackStatusCounts( + IMediator mediator, + CurrentUser currentUser, + long userId, CancellationToken cancellationToken) { - var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize), cancellationToken); + if (currentUser.RequiredId != userId) + return Results.Problem("You can only read the status counts of your own tracks.", statusCode: StatusCodes.Status403Forbidden); + + var result = await mediator.Send(new GetTrackStatusCountsQuery(userId), cancellationToken); + return Results.Ok(result); + } + + private static async Task DeleteTracks( + IMediator mediator, + CurrentUser currentUser, + [FromBody] DeleteTracksRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new DeleteTracksCommand(currentUser.RequiredId, request.TrackIds), cancellationToken); return result.ToHttpResult(); } diff --git a/backend/Hosts/Musify.Api/Validators/Tracks/DeleteTracksRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Tracks/DeleteTracksRequestValidator.cs new file mode 100644 index 0000000..436c43f --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/Tracks/DeleteTracksRequestValidator.cs @@ -0,0 +1,21 @@ +using FluentValidation; +using Musify.Api.DataTransferObjects.Tracks; + +namespace Musify.Api.Validators.Tracks +{ + public sealed class DeleteTracksRequestValidator : AbstractValidator + { + public const int MaxTracksPerRequest = 100; + + public DeleteTracksRequestValidator() + { + RuleFor(x => x.TrackIds) + .NotEmpty() + .Must(ids => ids.Count <= MaxTracksPerRequest) + .WithMessage($"At most {MaxTracksPerRequest} tracks can be deleted at once."); + + RuleForEach(x => x.TrackIds) + .NotEmpty(); + } + } +} diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/TrackLibraryEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/TrackLibraryEndpointsTests.cs new file mode 100644 index 0000000..3447fc5 --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Endpoints/TrackLibraryEndpointsTests.cs @@ -0,0 +1,220 @@ +using System.Globalization; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Musify.Api.DataTransferObjects.Tracks; +using Musify.Api.Tests.TestSupport; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Persistence; +using Xunit; + +namespace Musify.Api.Tests.Endpoints +{ + [Collection(ApiCollection.Name)] + public sealed class TrackLibraryEndpointsTests(ApiTestFixture fixture) + { + private async Task SeedTrackAsync( + long ownerId, + string title, + ProcessingStatus status = ProcessingStatus.Completed) + { + using var scope = fixture.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + OwnerUserId = ownerId, + Audio = new TrackAudio { TranscodeStatus = status, FolderName = status == ProcessingStatus.Completed ? "folder" : null } + }; + database.Tracks.Add(track); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + return track.Id; + } + + private async Task SeedAlbumWithTracksAsync(long ownerId, params Guid[] trackIds) + { + using var scope = fixture.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + var album = new Album { Title = "Album", NormalizedTitle = "ALBUM", OwnerUserId = ownerId }; + database.Albums.Add(album); + var number = 1; + foreach (var trackId in trackIds) + database.AlbumHasTracks.Add(new AlbumHasTrack { AlbumId = album.Id, TrackId = trackId, TrackNumber = number++ }); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + return album.Id; + } + + private static int Number(JsonElement element) => + element.ValueKind == JsonValueKind.String + ? int.Parse(element.GetString()!, CultureInfo.InvariantCulture) + : element.GetInt32(); + + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + var text = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + return JsonDocument.Parse(text).RootElement.Clone(); + } + + [Fact] + public async Task GetUserTracks_StatusFilterBindsRepeatedQueryValues() + { + var userId = await fixture.SeedUserAsync(); + await SeedTrackAsync(userId, "Done"); + await SeedTrackAsync(userId, "Pending", ProcessingStatus.Pending); + await SeedTrackAsync(userId, "Failed", ProcessingStatus.Failed); + + var response = await fixture.CreateAnonymousClient().GetAsync( + $"/tracks/users/{userId.ToString(CultureInfo.InvariantCulture)}?status=Pending&status=Failed", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await ReadJsonAsync(response); + var titles = body.GetProperty("items").EnumerateArray().Select(i => i.GetProperty("title").GetString()).Order().ToList(); + Assert.Equal(["Failed", "Pending"], titles); + Assert.Equal(2, Number(body.GetProperty("totalItemCount"))); + } + + [Fact] + public async Task GetUserTracks_ReturnsAlbumsOfEachTrack() + { + var userId = await fixture.SeedUserAsync(); + var trackId = await SeedTrackAsync(userId, "In album"); + var albumId = await SeedAlbumWithTracksAsync(userId, trackId); + + var response = await fixture.CreateAnonymousClient().GetAsync( + $"/tracks/users/{userId.ToString(CultureInfo.InvariantCulture)}", TestContext.Current.CancellationToken); + + var body = await ReadJsonAsync(response); + var albums = body.GetProperty("items")[0].GetProperty("albums"); + Assert.Equal(1, albums.GetArrayLength()); + Assert.Equal(albumId, albums[0].GetProperty("id").GetGuid()); + Assert.Equal("Album", albums[0].GetProperty("title").GetString()); + } + + [Fact] + public async Task GetStatusCounts_Owner_ReturnsCountsPerStatus() + { + var userId = await fixture.SeedUserAsync(); + await SeedTrackAsync(userId, "A"); + await SeedTrackAsync(userId, "B", ProcessingStatus.Processing); + await SeedTrackAsync(userId, "C", ProcessingStatus.Failed); + + var response = await fixture.CreateAuthenticatedClient(userId).GetAsync( + $"/tracks/users/{userId.ToString(CultureInfo.InvariantCulture)}/status-counts", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await ReadJsonAsync(response); + Assert.Equal(3, Number(body.GetProperty("total"))); + Assert.Equal(1, Number(body.GetProperty("completed"))); + Assert.Equal(1, Number(body.GetProperty("processing"))); + Assert.Equal(1, Number(body.GetProperty("failed"))); + Assert.Equal(0, Number(body.GetProperty("pending"))); + } + + [Fact] + public async Task GetStatusCounts_AnotherUser_ReturnsForbidden() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + + var response = await fixture.CreateAuthenticatedClient(stranger).GetAsync( + $"/tracks/users/{owner.ToString(CultureInfo.InvariantCulture)}/status-counts", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task GetStatusCounts_Anonymous_ReturnsUnauthorized() + { + var response = await fixture.CreateAnonymousClient().GetAsync("/tracks/users/1/status-counts", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task GetAlbumTrackIds_Owner_ReturnsIdsInOrder() + { + var userId = await fixture.SeedUserAsync(); + var first = await SeedTrackAsync(userId, "First"); + var second = await SeedTrackAsync(userId, "Second"); + var albumId = await SeedAlbumWithTracksAsync(userId, second, first); + + var response = await fixture.CreateAuthenticatedClient(userId).GetAsync($"/albums/{albumId}/tracks/ids", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await ReadJsonAsync(response); + var ids = body.GetProperty("trackIds").EnumerateArray().Select(e => e.GetGuid()).ToList(); + Assert.Equal([second, first], ids); + } + + [Fact] + public async Task GetAlbumTrackIds_NotTheOwner_ReturnsForbidden() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var albumId = await SeedAlbumWithTracksAsync(owner); + + var response = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/albums/{albumId}/tracks/ids", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task DeleteTracks_Anonymous_ReturnsUnauthorized() + { + var request = new HttpRequestMessage(HttpMethod.Delete, "/tracks") { Content = JsonContent.Create(new DeleteTracksRequest([Guid.NewGuid()])) }; + + var response = await fixture.CreateAnonymousClient().SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task DeleteTracks_EmptyList_ReturnsBadRequest() + { + var userId = await fixture.SeedUserAsync(); + var request = new HttpRequestMessage(HttpMethod.Delete, "/tracks") { Content = JsonContent.Create(new DeleteTracksRequest([])) }; + + var response = await fixture.CreateAuthenticatedClient(userId).SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task DeleteTracks_OwnTracks_MarksThemRemovingAndReportsFailures() + { + var userId = await fixture.SeedUserAsync(); + var done = await SeedTrackAsync(userId, "Done"); + var busy = await SeedTrackAsync(userId, "Busy", ProcessingStatus.Pending); + var request = new HttpRequestMessage(HttpMethod.Delete, "/tracks") { Content = JsonContent.Create(new DeleteTracksRequest([done, busy])) }; + + var response = await fixture.CreateAuthenticatedClient(userId).SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await ReadJsonAsync(response); + Assert.Equal([done], body.GetProperty("deletedTrackIds").EnumerateArray().Select(e => e.GetGuid()).ToList()); + Assert.Equal([busy], body.GetProperty("failedTrackIds").EnumerateArray().Select(e => e.GetGuid()).ToList()); + + using var scope = fixture.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + var stored = await database.Tracks.AsNoTracking().SingleAsync(t => t.Id == done, TestContext.Current.CancellationToken); + Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus); + } + + [Fact] + public async Task DeleteTracks_TrackOfAnotherUser_ReturnsForbidden() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var trackId = await SeedTrackAsync(owner, "Theirs"); + var request = new HttpRequestMessage(HttpMethod.Delete, "/tracks") { Content = JsonContent.Create(new DeleteTracksRequest([trackId])) }; + + var response = await fixture.CreateAuthenticatedClient(stranger).SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTrackIdsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTrackIdsQueryHandlerTests.cs new file mode 100644 index 0000000..6a05ad9 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTrackIdsQueryHandlerTests.cs @@ -0,0 +1,53 @@ +using ErrorOr; +using Musify.Application.Albums; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Application.Tests.Albums +{ + public sealed class GetAlbumTrackIdsQueryHandlerTests : HandlerTestBase + { + private GetAlbumTrackIdsQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_Owner_ReturnsAllActiveTrackIdsInOrderBeyondAPage() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + var tracks = Enumerable.Range(1, 120).Select(i => TestEntities.Track(owner, $"T{i}")).ToList(); + var removing = TestEntities.Track(owner, "Removing", lifeCycleStatus: LifeCycleStatus.Removing); + await SeedAsync([owner, album, removing, .. tracks]); + var links = tracks.Select((t, i) => new AlbumHasTrack { AlbumId = album.Id, TrackId = t.Id, TrackNumber = i + 1 }).ToList(); + links.Add(new AlbumHasTrack { AlbumId = album.Id, TrackId = removing.Id, TrackNumber = 999 }); + await SeedAsync([.. links]); + + var result = await CreateHandler().Handle(new GetAlbumTrackIdsQuery(owner.Id, album.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(tracks.Select(t => t.Id), result.Value.TrackIds); + } + + [Fact] + public async Task Handle_NotOwner_ReturnsForbidden() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, stranger, album); + + var result = await CreateHandler().Handle(new GetAlbumTrackIdsQuery(stranger.Id, album.Id), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } + + [Fact] + public async Task Handle_MissingAlbum_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetAlbumTrackIdsQuery(1, Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/DeleteTracksCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTracksCommandHandlerTests.cs new file mode 100644 index 0000000..3fa8a6d --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTracksCommandHandlerTests.cs @@ -0,0 +1,81 @@ +using ErrorOr; +using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks; +using Musify.Domain.ValueObjects; +using NSubstitute; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class DeleteTracksCommandHandlerTests : HandlerTestBase + { + private readonly IEventBus eventBus = Substitute.For(); + + private DeleteTracksCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); + + [Fact] + public async Task Handle_OwnTracks_MarksAllAsRemovingAndPublishesOneEventEach() + { + var owner = TestEntities.User(); + var first = TestEntities.Track(owner, "One"); + var second = TestEntities.Track(owner, "Two"); + await SeedAsync(owner, first, second); + + var result = await CreateHandler().Handle(new DeleteTracksCommand(owner.Id, [first.Id, second.Id]), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(2, result.Value.DeletedTrackIds.Count); + Assert.Empty(result.Value.FailedTrackIds); + Assert.All(Database.Tracks.ToList(), track => Assert.Equal(LifeCycleStatus.Removing, track.LifeCycleStatus)); + await eventBus.Received(2).PublishAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_TrackOfAnotherUser_ReturnsForbiddenAndDeletesNothing() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var mine = TestEntities.Track(owner, "Mine"); + var theirs = TestEntities.Track(stranger, "Theirs"); + await SeedAsync(owner, stranger, mine, theirs); + + var result = await CreateHandler().Handle(new DeleteTracksCommand(owner.Id, [mine.Id, theirs.Id]), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + Assert.All(Database.Tracks.ToList(), track => Assert.Equal(LifeCycleStatus.Active, track.LifeCycleStatus)); + await eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_ProcessingAndMissingTracks_AreReturnedAsFailed() + { + var owner = TestEntities.User(); + var done = TestEntities.Track(owner, "Done"); + var busy = TestEntities.Track(owner, "Busy", audio: TestEntities.PendingAudio()); + var missing = Guid.NewGuid(); + await SeedAsync(owner, done, busy); + + var result = await CreateHandler().Handle(new DeleteTracksCommand(owner.Id, [done.Id, busy.Id, missing]), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal([done.Id], result.Value.DeletedTrackIds); + Assert.Equal([busy.Id, missing], result.Value.FailedTrackIds); + Assert.Equal(LifeCycleStatus.Active, Database.Tracks.Single(t => t.Id == busy.Id).LifeCycleStatus); + } + + [Fact] + public async Task Handle_DuplicateIds_AreDeletedOnce() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); + + var result = await CreateHandler().Handle(new DeleteTracksCommand(owner.Id, [track.Id, track.Id]), TestContext.Current.CancellationToken); + + Assert.Single(result.Value.DeletedTrackIds); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStatusCountsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStatusCountsQueryHandlerTests.cs new file mode 100644 index 0000000..044988a --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStatusCountsQueryHandlerTests.cs @@ -0,0 +1,45 @@ +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks; +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class GetTrackStatusCountsQueryHandlerTests : HandlerTestBase + { + [Fact] + public async Task Handle_CountsOnlyActiveTracksOfTheUser() + { + var owner = TestEntities.User(1, "owner"); + var other = TestEntities.User(2, "other"); + await SeedAsync( + owner, other, + TestEntities.Track(owner, "Done 1"), + TestEntities.Track(owner, "Done 2"), + TestEntities.Track(owner, "Pending", audio: TestEntities.PendingAudio()), + TestEntities.Track(owner, "Processing", audio: new TrackAudio { TranscodeStatus = ProcessingStatus.Processing }), + TestEntities.Track(owner, "Failed", audio: new TrackAudio { TranscodeStatus = ProcessingStatus.Failed }), + TestEntities.Track(owner, "Removing", lifeCycleStatus: LifeCycleStatus.Removing), + TestEntities.Track(other, "Theirs")); + + var counts = await new GetTrackStatusCountsQueryHandler(Database) + .Handle(new GetTrackStatusCountsQuery(owner.Id), TestContext.Current.CancellationToken); + + Assert.Equal(5, counts.Total); + Assert.Equal(1, counts.Pending); + Assert.Equal(1, counts.Processing); + Assert.Equal(2, counts.Completed); + Assert.Equal(1, counts.Failed); + } + + [Fact] + public async Task Handle_NoTracks_ReturnsZeros() + { + var counts = await new GetTrackStatusCountsQueryHandler(Database) + .Handle(new GetTrackStatusCountsQuery(99), TestContext.Current.CancellationToken); + + Assert.Equal(0, counts.Total); + Assert.Equal(0, counts.Completed); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdStatusAndAlbumsTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdStatusAndAlbumsTests.cs new file mode 100644 index 0000000..8803b1b --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdStatusAndAlbumsTests.cs @@ -0,0 +1,79 @@ +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class GetTracksByUserIdStatusAndAlbumsTests : HandlerTestBase + { + private GetTracksByUserIdQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_StatusFilter_ReturnsOnlyMatchingStatuses() + { + var owner = TestEntities.User(); + var done = TestEntities.Track(owner, "Done"); + var pending = TestEntities.Track(owner, "Pending", audio: TestEntities.PendingAudio()); + var failed = TestEntities.Track(owner, "Failed", audio: new TrackAudio { TranscodeStatus = ProcessingStatus.Failed }); + await SeedAsync(owner, done, pending, failed); + + var result = await CreateHandler().Handle( + new GetTracksByUserIdQuery(owner.Id, null, 1, 10, [ProcessingStatus.Pending, ProcessingStatus.Failed]), + TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(["Failed", "Pending"], result.Value.Items.Select(t => t.Title).Order().ToList()); + Assert.Equal(2, result.Value.TotalItemCount); + } + + [Fact] + public async Task Handle_NoStatusFilter_ReturnsEveryStatus() + { + var owner = TestEntities.User(); + await SeedAsync(owner, TestEntities.Track(owner, "A"), TestEntities.Track(owner, "B", audio: TestEntities.PendingAudio())); + + var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 10), TestContext.Current.CancellationToken); + + Assert.Equal(2, result.Value.TotalItemCount); + } + + [Fact] + public async Task Handle_ListsActiveAlbumsOfEachTrack() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner, "Song"); + var loose = TestEntities.Track(owner, "Loose"); + var first = TestEntities.Album(owner.Id, "First"); + var second = TestEntities.Album(owner.Id, "Second"); + var removed = TestEntities.Album(owner.Id, "Gone"); + removed.LifeCycleStatus = LifeCycleStatus.Removing; + await SeedAsync( + owner, track, loose, first, second, removed, + new AlbumHasTrack { AlbumId = first.Id, TrackId = track.Id }, + new AlbumHasTrack { AlbumId = second.Id, TrackId = track.Id }, + new AlbumHasTrack { AlbumId = removed.Id, TrackId = track.Id }); + + var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, null, 1, 10), TestContext.Current.CancellationToken); + + var song = result.Value.Items.Single(t => t.Title == "Song"); + Assert.Equal(["First", "Second"], song.Albums.Select(a => a.Title).Order().ToList()); + Assert.Contains(song.Albums, a => a.Id == first.Id); + Assert.Empty(result.Value.Items.Single(t => t.Title == "Loose").Albums); + } + + [Fact] + public async Task Handle_Paginates() + { + var owner = TestEntities.User(); + await SeedAsync(owner, TestEntities.Track(owner, "A"), TestEntities.Track(owner, "B"), TestEntities.Track(owner, "C")); + + var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, null, 2, 2), TestContext.Current.CancellationToken); + + Assert.Single(result.Value.Items); + Assert.Equal(3, result.Value.TotalItemCount); + Assert.True(result.Value.HasPreviousPage); + } + } +}