diff --git a/backend/.editorconfig b/backend/.editorconfig index bcc34a2..e78ac4b 100644 --- a/backend/.editorconfig +++ b/backend/.editorconfig @@ -18,7 +18,7 @@ generated_code = true [*.cs] indent_size = 4 -csharp_style_namespace_declarations = file_scoped:warning +csharp_style_namespace_declarations = block_scoped:warning csharp_using_directive_placement = outside_namespace:warning dotnet_sort_system_directives_first = true dotnet_separate_import_directive_groups = false diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index a8545e7..5a29132 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -19,7 +19,7 @@ dotnet format Musify.slnx --verify-no-changes ## Style -- File-scoped namespaces, usings outside the namespace and sorted (`.editorconfig` enforces it). +- Block-scoped namespaces (`namespace X { }`; file-scoped `namespace X;` is not allowed), usings outside the namespace and sorted (`.editorconfig` enforces it). - Private fields camelCase without underscore, constants and `static readonly` fields PascalCase, explicit accessibility. - `ArgumentNullException.ThrowIfNull(x)` without `nameof`. - Use `PlayList` (not `Playlist`) in code; configuration POCOs end in `Configuration`; injected services are named after their type (`storageService`). diff --git a/backend/Core/Musify.Application/Albums/AddTracksToAlbum.cs b/backend/Core/Musify.Application/Albums/AddTracksToAlbum.cs index 39a0679..455660e 100644 --- a/backend/Core/Musify.Application/Albums/AddTracksToAlbum.cs +++ b/backend/Core/Musify.Application/Albums/AddTracksToAlbum.cs @@ -7,71 +7,72 @@ using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Albums; - -/// Appends your own tracks to an album in the given order, skipping the ones it already has. -public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList TrackIds) - : ICommand>; - -public class AddTracksToAlbumCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> +namespace Musify.Application.Albums { - public async ValueTask> Handle(AddTracksToAlbumCommand request, CancellationToken cancellationToken) + /// Appends your own tracks to an album in the given order, skipping the ones it already has. + public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList TrackIds) + : ICommand>; + + public class AddTracksToAlbumCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> { - var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); - if (album.IsError) - return album.Errors; - - var requestedIds = request.TrackIds.Distinct().ToList(); - - var tracks = await database.Tracks - .AsNoTracking() - .Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) - .Select(track => new { track.Id, track.OwnerUserId }) - .ToListAsync(cancellationToken); - - var missing = requestedIds.FirstOrDefault(id => tracks.All(track => track.Id != id)); - if (missing != Guid.Empty) - return AppErrors.NotFound("Track", missing); - - var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId); - if (foreign != null) - return AppErrors.Forbidden("Track", foreign.Id); - - var alreadyInAlbum = await database.AlbumHasTracks - .AsNoTracking() - .Where(link => link.AlbumId == request.AlbumId && requestedIds.Contains(link.TrackId)) - .Select(link => link.TrackId) - .ToListAsync(cancellationToken); - - var toAdd = requestedIds.Where(id => !alreadyInAlbum.Contains(id)).ToList(); - if (toAdd.Count == 0) - return Result.Success; - - // Album track numbers are user-visible, so they start at 1. - var nextTrackNumber = (await database.AlbumHasTracks - .Where(link => link.AlbumId == request.AlbumId) - .MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1; - - foreach (var trackId in toAdd) + public async ValueTask> Handle(AddTracksToAlbumCommand request, CancellationToken cancellationToken) { - await database.AlbumHasTracks.AddAsync(new AlbumHasTrack + var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); + if (album.IsError) + return album.Errors; + + var requestedIds = request.TrackIds.Distinct().ToList(); + + var tracks = await database.Tracks + .AsNoTracking() + .Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) + .Select(track => new { track.Id, track.OwnerUserId }) + .ToListAsync(cancellationToken); + + var missing = requestedIds.FirstOrDefault(id => tracks.All(track => track.Id != id)); + if (missing != Guid.Empty) + return AppErrors.NotFound("Track", missing); + + var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId); + if (foreign != null) + return AppErrors.Forbidden("Track", foreign.Id); + + var alreadyInAlbum = await database.AlbumHasTracks + .AsNoTracking() + .Where(link => link.AlbumId == request.AlbumId && requestedIds.Contains(link.TrackId)) + .Select(link => link.TrackId) + .ToListAsync(cancellationToken); + + var toAdd = requestedIds.Where(id => !alreadyInAlbum.Contains(id)).ToList(); + if (toAdd.Count == 0) + return Result.Success; + + // Album track numbers are user-visible, so they start at 1. + var nextTrackNumber = (await database.AlbumHasTracks + .Where(link => link.AlbumId == request.AlbumId) + .MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1; + + foreach (var trackId in toAdd) { - AlbumId = request.AlbumId, - TrackId = trackId, - TrackNumber = nextTrackNumber++ - }, cancellationToken); + await database.AlbumHasTracks.AddAsync(new AlbumHasTrack + { + AlbumId = request.AlbumId, + TrackId = trackId, + TrackNumber = nextTrackNumber++ + }, cancellationToken); + } + + var saved = await database.TrySaveChangesAsync( + AppErrors.Conflict("Album.ConcurrentChange", "The album changed while adding tracks. Try again."), cancellationToken); + if (saved.IsError) + return saved; + + logger.LogInformation("Added {Count} tracks to album {AlbumId}", toAdd.Count, request.AlbumId); + + return Result.Success; } - - var saved = await database.TrySaveChangesAsync( - AppErrors.Conflict("Album.ConcurrentChange", "The album changed while adding tracks. Try again."), cancellationToken); - if (saved.IsError) - return saved; - - logger.LogInformation("Added {Count} tracks to album {AlbumId}", toAdd.Count, request.AlbumId); - - return Result.Success; } } diff --git a/backend/Core/Musify.Application/Albums/CreateAlbum.cs b/backend/Core/Musify.Application/Albums/CreateAlbum.cs index de98485..8be75e6 100644 --- a/backend/Core/Musify.Application/Albums/CreateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/CreateAlbum.cs @@ -12,61 +12,62 @@ using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Albums; - -public record CreateAlbumCommand( - long UserId, - string Title, - string? Description, - int? ReleaseYear, - Guid PictureIntentId) - : ICommand>; - -public class CreateAlbumCommandHandler( - IEventBus eventBus, - IDatabase database, - UploadIntentValidator uploadIntentValidator, - ILogger logger, - AlbumConfiguration albumConfiguration) - : ICommandHandler> +namespace Musify.Application.Albums { - public async ValueTask> Handle(CreateAlbumCommand request, CancellationToken cancellationToken) + public record CreateAlbumCommand( + long UserId, + string Title, + string? Description, + int? ReleaseYear, + Guid PictureIntentId) + : ICommand>; + + public class CreateAlbumCommandHandler( + IEventBus eventBus, + IDatabase database, + UploadIntentValidator uploadIntentValidator, + ILogger logger, + AlbumConfiguration albumConfiguration) + : ICommandHandler> { - var userExists = await database.Users - .AsNoTracking() - .AnyAsync(user => user.Id == request.UserId, cancellationToken); - if (!userExists) - return AppErrors.NotFound("User", request.UserId); - - var prepared = await PictureSourceChange.PrepareAsync( - uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken); - if (prepared.IsError) - return prepared.Errors; - - var picture = prepared.Value; - var title = request.Title.Trim(); - - var album = new Album + public async ValueTask> Handle(CreateAlbumCommand request, CancellationToken cancellationToken) { - OwnerUserId = request.UserId, - Title = title, - NormalizedTitle = TextNormalizer.Normalize(title), - Description = request.Description, - ReleaseYear = request.ReleaseYear, - Pictures = picture.Pictures - }; + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(user => user.Id == request.UserId, cancellationToken); + if (!userExists) + return AppErrors.NotFound("User", request.UserId); - await database.Albums.AddAsync(album, cancellationToken); + var prepared = await PictureSourceChange.PrepareAsync( + uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken); + if (prepared.IsError) + return prepared.Errors; - await eventBus.PublishAsync( - new CreateAlbumResourcesEvent( - album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), - cancellationToken); + var picture = prepared.Value; + var title = request.Title.Trim(); - await database.SaveChangesAsync(cancellationToken); + var album = new Album + { + OwnerUserId = request.UserId, + Title = title, + NormalizedTitle = TextNormalizer.Normalize(title), + Description = request.Description, + ReleaseYear = request.ReleaseYear, + Pictures = picture.Pictures + }; - logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId); + await database.Albums.AddAsync(album, cancellationToken); - return AlbumApplicationResponse.FromEntity(album, trackCount: 0); + await eventBus.PublishAsync( + new CreateAlbumResourcesEvent( + album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), + cancellationToken); + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId); + + return AlbumApplicationResponse.FromEntity(album, trackCount: 0); + } } } diff --git a/backend/Core/Musify.Application/Albums/DeleteAlbum.cs b/backend/Core/Musify.Application/Albums/DeleteAlbum.cs index bb4a96e..137aeb5 100644 --- a/backend/Core/Musify.Application/Albums/DeleteAlbum.cs +++ b/backend/Core/Musify.Application/Albums/DeleteAlbum.cs @@ -6,31 +6,32 @@ using Musify.Application.Events; using Musify.Application.Shared; using Musify.Domain.ValueObjects; -namespace Musify.Application.Albums; - -public record DeleteAlbumCommand(long UserId, Guid AlbumId) - : ICommand>; - -public class DeleteAlbumCommandHandler( - IDatabase database, - IEventBus eventBus, - ILogger logger) - : ICommandHandler> +namespace Musify.Application.Albums { - public async ValueTask> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken) + public record DeleteAlbumCommand(long UserId, Guid AlbumId) + : ICommand>; + + public class DeleteAlbumCommandHandler( + IDatabase database, + IEventBus eventBus, + ILogger logger) + : ICommandHandler> { - var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); - if (found.IsError) - return found.Errors; + public async ValueTask> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken) + { + var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; - var album = found.Value; - album.LifeCycleStatus = LifeCycleStatus.Removing; + var album = found.Value; + album.LifeCycleStatus = LifeCycleStatus.Removing; - await eventBus.PublishAsync(new DeleteAlbumEvent(album.Id, request.UserId), cancellationToken); - await database.SaveChangesAsync(cancellationToken); + await eventBus.PublishAsync(new DeleteAlbumEvent(album.Id, request.UserId), cancellationToken); + await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Marked album {AlbumId} as removing", request.AlbumId); + logger.LogInformation("Marked album {AlbumId} as removing", request.AlbumId); - return Result.Success; + return Result.Success; + } } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumById.cs b/backend/Core/Musify.Application/Albums/GetAlbumById.cs index 20856b6..5461a17 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumById.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumById.cs @@ -5,25 +5,26 @@ using Musify.Application.Albums.Responses; using Musify.Application.Contracts; using Musify.Application.Shared; -namespace Musify.Application.Albums; - -public record GetAlbumByIdQuery(Guid Id) - : IQuery>; - -public class GetAlbumByIdQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Albums { - public async ValueTask> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken) - { - var album = await database.Albums - .AsNoTracking() - .Active() - .Where(a => a.Id == request.Id) - .SelectResponse() - .SingleOrDefaultAsync(cancellationToken); + public record GetAlbumByIdQuery(Guid Id) + : IQuery>; - return album == null - ? AppErrors.NotFound("Album", request.Id) - : album; + public class GetAlbumByIdQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken) + { + var album = await database.Albums + .AsNoTracking() + .Active() + .Where(a => a.Id == request.Id) + .SelectResponse() + .SingleOrDefaultAsync(cancellationToken); + + return album == null + ? AppErrors.NotFound("Album", request.Id) + : album; + } } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs index ba9955a..7da6847 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs @@ -6,33 +6,34 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -namespace Musify.Application.Albums; - -public record GetAlbumTracksQuery( - Guid AlbumId, - int PageNumber, - int PageSize) - : IQuery>>; - -public class GetAlbumTracksQueryHandler(IDatabase database) - : IQueryHandler>> +namespace Musify.Application.Albums { - public async ValueTask>> Handle(GetAlbumTracksQuery request, CancellationToken cancellationToken) - { - var albumExists = await database.Albums - .AsNoTracking() - .Active() - .AnyAsync(album => album.Id == request.AlbumId, cancellationToken); - if (!albumExists) - return AppErrors.NotFound("Album", request.AlbumId); + public record GetAlbumTracksQuery( + Guid AlbumId, + int PageNumber, + int PageSize) + : IQuery>>; - return await database.AlbumHasTracks - .AsNoTracking() - .Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active) - .OrderBy(albumTrack => albumTrack.TrackNumber) - .ThenBy(albumTrack => albumTrack.Id) - .Select(albumTrack => albumTrack.Track) - .SelectResponse() - .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + public class GetAlbumTracksQueryHandler(IDatabase database) + : IQueryHandler>> + { + public async ValueTask>> Handle(GetAlbumTracksQuery request, CancellationToken cancellationToken) + { + var albumExists = await database.Albums + .AsNoTracking() + .Active() + .AnyAsync(album => album.Id == request.AlbumId, cancellationToken); + if (!albumExists) + return AppErrors.NotFound("Album", request.AlbumId); + + return await database.AlbumHasTracks + .AsNoTracking() + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active) + .OrderBy(albumTrack => albumTrack.TrackNumber) + .ThenBy(albumTrack => albumTrack.Id) + .Select(albumTrack => albumTrack.Track) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + } } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbums.cs b/backend/Core/Musify.Application/Albums/GetAlbums.cs index 0c7143e..2da6cbc 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbums.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbums.cs @@ -4,30 +4,31 @@ using Musify.Application.Albums.Responses; using Musify.Application.Contracts; using Musify.Application.Shared; -namespace Musify.Application.Albums; - -public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize) - : IQuery>; - -public class GetAlbumsQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Albums { - public async ValueTask> Handle(GetAlbumsQuery request, CancellationToken cancellationToken) + public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize) + : IQuery>; + + public class GetAlbumsQueryHandler(IDatabase database) + : IQueryHandler> { - var albumsQuery = database.Albums - .AsNoTracking() - .Active(); - - if (!string.IsNullOrWhiteSpace(request.Title)) + public async ValueTask> Handle(GetAlbumsQuery request, CancellationToken cancellationToken) { - var normalizedTitle = TextNormalizer.Normalize(request.Title); - albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle)); - } + var albumsQuery = database.Albums + .AsNoTracking() + .Active(); - return await albumsQuery - .OrderByDescending(album => album.CreatedAt) - .ThenBy(album => album.Id) - .SelectResponse() - .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + if (!string.IsNullOrWhiteSpace(request.Title)) + { + var normalizedTitle = TextNormalizer.Normalize(request.Title); + albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle)); + } + + return await albumsQuery + .OrderByDescending(album => album.CreatedAt) + .ThenBy(album => album.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + } } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs b/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs index dfe03d2..53fe0d7 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs @@ -5,29 +5,30 @@ using Musify.Application.Albums.Responses; using Musify.Application.Contracts; using Musify.Application.Shared; -namespace Musify.Application.Albums; - -public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize) - : IQuery>>; - -public class GetAlbumsByUserIdQueryHandler(IDatabase database) - : IQueryHandler>> +namespace Musify.Application.Albums { - public async ValueTask>> Handle(GetAlbumsByUserIdQuery request, CancellationToken cancellationToken) - { - var userExists = await database.Users - .AsNoTracking() - .AnyAsync(user => user.Id == request.UserId, cancellationToken); - if (!userExists) - return AppErrors.NotFound("User", request.UserId); + public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize) + : IQuery>>; - return await database.Albums - .AsNoTracking() - .Active() - .Where(album => album.OwnerUserId == request.UserId) - .OrderByDescending(album => album.CreatedAt) - .ThenBy(album => album.Id) - .SelectResponse() - .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + public class GetAlbumsByUserIdQueryHandler(IDatabase database) + : IQueryHandler>> + { + public async ValueTask>> Handle(GetAlbumsByUserIdQuery request, CancellationToken cancellationToken) + { + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(user => user.Id == request.UserId, cancellationToken); + if (!userExists) + return AppErrors.NotFound("User", request.UserId); + + return await database.Albums + .AsNoTracking() + .Active() + .Where(album => album.OwnerUserId == request.UserId) + .OrderByDescending(album => album.CreatedAt) + .ThenBy(album => album.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + } } } diff --git a/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs b/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs index c038271..2f87e1a 100644 --- a/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs +++ b/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs @@ -3,49 +3,50 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; -namespace Musify.Application.Albums; - -public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit) - : IQuery>; - -public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Albums { - public async ValueTask> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken) + public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit) + : IQuery>; + + public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database) + : IQueryHandler> { - var listens = database.ListeningHistories - .AsNoTracking() - .Where(history => history.UserId == request.UserId && history.IsCounted); + public async ValueTask> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken) + { + var listens = database.ListeningHistories + .AsNoTracking() + .Where(history => history.UserId == request.UserId && history.IsCounted); - var ranked = await database.AlbumHasTracks - .AsNoTracking() - .Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active) - .Join( - listens, - albumTrack => albumTrack.TrackId, - history => history.TrackId, - (albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt }) - .GroupBy(entry => entry.AlbumId) - .Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) }) - .OrderByDescending(entry => entry.LastListenedAt) - .ThenBy(entry => entry.AlbumId) - .Take(request.Limit) - .ToListAsync(cancellationToken); + var ranked = await database.AlbumHasTracks + .AsNoTracking() + .Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active) + .Join( + listens, + albumTrack => albumTrack.TrackId, + history => history.TrackId, + (albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt }) + .GroupBy(entry => entry.AlbumId) + .Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) }) + .OrderByDescending(entry => entry.LastListenedAt) + .ThenBy(entry => entry.AlbumId) + .Take(request.Limit) + .ToListAsync(cancellationToken); - if (ranked.Count == 0) - return []; + if (ranked.Count == 0) + return []; - var albumIds = ranked.Select(entry => entry.AlbumId).ToList(); + var albumIds = ranked.Select(entry => entry.AlbumId).ToList(); - var albumsById = await database.Albums - .AsNoTracking() - .Where(album => albumIds.Contains(album.Id)) - .SelectResponse() - .ToDictionaryAsync(album => album.Id, cancellationToken); + var albumsById = await database.Albums + .AsNoTracking() + .Where(album => albumIds.Contains(album.Id)) + .SelectResponse() + .ToDictionaryAsync(album => album.Id, cancellationToken); - return ranked - .Where(entry => albumsById.ContainsKey(entry.AlbumId)) - .Select(entry => albumsById[entry.AlbumId]) - .ToList(); + return ranked + .Where(entry => albumsById.ContainsKey(entry.AlbumId)) + .Select(entry => albumsById[entry.AlbumId]) + .ToList(); + } } } diff --git a/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs b/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs index 2edb9c9..04849fd 100644 --- a/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs +++ b/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs @@ -5,41 +5,42 @@ using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Application.Shared; -namespace Musify.Application.Albums; - -public record RemoveTrackFromAlbumCommand(long UserId, Guid AlbumId, Guid TrackId) - : ICommand>; - -public class RemoveTrackFromAlbumCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> +namespace Musify.Application.Albums { - public async ValueTask> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken) + public record RemoveTrackFromAlbumCommand(long UserId, Guid AlbumId, Guid TrackId) + : ICommand>; + + public class RemoveTrackFromAlbumCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> { - var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); - if (album.IsError) - return album.Errors; + public async ValueTask> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken) + { + var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); + if (album.IsError) + return album.Errors; - var albumTracks = await database.AlbumHasTracks - .Where(albumTrack => albumTrack.AlbumId == request.AlbumId) - .OrderBy(albumTrack => albumTrack.TrackNumber) - .ToListAsync(cancellationToken); + var albumTracks = await database.AlbumHasTracks + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId) + .OrderBy(albumTrack => albumTrack.TrackNumber) + .ToListAsync(cancellationToken); - var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId); - if (link == null) - return Error.NotFound("Album.TrackNotFound", "Track is not in the album."); + var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId); + if (link == null) + return Error.NotFound("Album.TrackNotFound", "Track is not in the album."); - database.AlbumHasTracks.Remove(link); + database.AlbumHasTracks.Remove(link); - var trackNumber = 1; - foreach (var remaining in albumTracks.Where(albumTrack => albumTrack.Id != link.Id)) - remaining.TrackNumber = trackNumber++; + var trackNumber = 1; + foreach (var remaining in albumTracks.Where(albumTrack => albumTrack.Id != link.Id)) + remaining.TrackNumber = trackNumber++; - await database.SaveChangesAsync(cancellationToken); + await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId); + logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId); - return Result.Success; + return Result.Success; + } } } diff --git a/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs b/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs index 14fdd71..68c0dfb 100644 --- a/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs +++ b/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs @@ -5,20 +5,21 @@ using Musify.Application.Pictures.Responses; using Musify.Application.Services; using Musify.Domain.ValueObjects; -namespace Musify.Application.Albums; - -public record RequestAlbumPictureUploadCommand( - long UserId, - string FileType, - string ContentType, - long? ExpectedSizeBytes = null) - : ICommand>; - -public class RequestAlbumPictureUploadCommandHandler(UploadIntentIssuer issuer, AlbumConfiguration albumConfiguration) - : ICommandHandler> +namespace Musify.Application.Albums { - public async ValueTask> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) => - await issuer.IssuePictureAsync( - request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, - request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken); + public record RequestAlbumPictureUploadCommand( + long UserId, + string FileType, + string ContentType, + long? ExpectedSizeBytes = null) + : ICommand>; + + public class RequestAlbumPictureUploadCommandHandler(UploadIntentIssuer issuer, AlbumConfiguration albumConfiguration) + : ICommandHandler> + { + public async ValueTask> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) => + await issuer.IssuePictureAsync( + request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, + request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken); + } } diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs index 5a97f89..77ce78e 100644 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs @@ -1,40 +1,41 @@ using Musify.Domain.Entities; -namespace Musify.Application.Albums.Responses; - -public record AlbumApplicationResponse( - Guid Id, - string Title, - string? Description, - int? ReleaseYear, - long OwnerUserId, - int TrackCount, - string? SmallImageKeyName, - string? MediumImageKeyName, - string? LargeImageKeyName, - DateTime CreatedAt, - DateTime UpdatedAt, - IReadOnlyList CoverTrackIds) +namespace Musify.Application.Albums.Responses { - public const int CoverTrackCount = AlbumProjections.CoverTrackCount; - - public static AlbumApplicationResponse FromEntity( - Album album, - int trackCount, - IReadOnlyList? coverTrackIds = null) + public record AlbumApplicationResponse( + Guid Id, + string Title, + string? Description, + int? ReleaseYear, + long OwnerUserId, + int TrackCount, + string? SmallImageKeyName, + string? MediumImageKeyName, + string? LargeImageKeyName, + DateTime CreatedAt, + DateTime UpdatedAt, + IReadOnlyList CoverTrackIds) { - return new AlbumApplicationResponse( - album.Id, - album.Title, - album.Description, - album.ReleaseYear, - album.OwnerUserId, - trackCount, - album.Pictures?.SmallName, - album.Pictures?.MediumName, - album.Pictures?.LargeName, - album.CreatedAt, - album.UpdatedAt, - coverTrackIds ?? []); + public const int CoverTrackCount = AlbumProjections.CoverTrackCount; + + public static AlbumApplicationResponse FromEntity( + Album album, + int trackCount, + IReadOnlyList? coverTrackIds = null) + { + return new AlbumApplicationResponse( + album.Id, + album.Title, + album.Description, + album.ReleaseYear, + album.OwnerUserId, + trackCount, + album.Pictures?.SmallName, + album.Pictures?.MediumName, + album.Pictures?.LargeName, + album.CreatedAt, + album.UpdatedAt, + coverTrackIds ?? []); + } } } diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumProjections.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumProjections.cs index 196d1d2..cf4b63b 100644 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumProjections.cs +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumProjections.cs @@ -1,31 +1,32 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Albums.Responses; - -public static class AlbumProjections +namespace Musify.Application.Albums.Responses { - public const int CoverTrackCount = 4; + public static class AlbumProjections + { + public const int CoverTrackCount = 4; - public static IQueryable Active(this IQueryable query) => - query.Where(album => album.LifeCycleStatus == LifeCycleStatus.Active); + public static IQueryable Active(this IQueryable query) => + query.Where(album => album.LifeCycleStatus == LifeCycleStatus.Active); - public static IQueryable SelectResponse(this IQueryable query) => - query.Select(album => new AlbumApplicationResponse( - album.Id, - album.Title, - album.Description, - album.ReleaseYear, - album.OwnerUserId, - album.AlbumTracks.Count, - album.Pictures!.SmallName, - album.Pictures!.MediumName, - album.Pictures!.LargeName, - album.CreatedAt, - album.UpdatedAt, - album.AlbumTracks - .OrderBy(albumTrack => albumTrack.TrackNumber) - .Take(CoverTrackCount) - .Select(albumTrack => albumTrack.TrackId) - .ToList())); + public static IQueryable SelectResponse(this IQueryable query) => + query.Select(album => new AlbumApplicationResponse( + album.Id, + album.Title, + album.Description, + album.ReleaseYear, + album.OwnerUserId, + album.AlbumTracks.Count, + album.Pictures!.SmallName, + album.Pictures!.MediumName, + album.Pictures!.LargeName, + album.CreatedAt, + album.UpdatedAt, + album.AlbumTracks + .OrderBy(albumTrack => albumTrack.TrackNumber) + .Take(CoverTrackCount) + .Select(albumTrack => albumTrack.TrackId) + .ToList())); + } } diff --git a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs index b4d798d..dc627dc 100644 --- a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs @@ -11,63 +11,64 @@ using Musify.Application.Services; using Musify.Application.Shared; using Musify.Domain.ValueObjects; -namespace Musify.Application.Albums; - -public record UpdateAlbumCommand( - long UserId, - Guid AlbumId, - string Title, - string? Description, - int? ReleaseYear, - Guid? NewPictureIntentId) - : ICommand>; - -public class UpdateAlbumCommandHandler( - IEventBus eventBus, - IDatabase database, - UploadIntentValidator uploadIntentValidator, - ILogger logger, - AlbumConfiguration albumConfiguration) - : ICommandHandler> +namespace Musify.Application.Albums { - public async ValueTask> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken) + public record UpdateAlbumCommand( + long UserId, + Guid AlbumId, + string Title, + string? Description, + int? ReleaseYear, + Guid? NewPictureIntentId) + : ICommand>; + + public class UpdateAlbumCommandHandler( + IEventBus eventBus, + IDatabase database, + UploadIntentValidator uploadIntentValidator, + ILogger logger, + AlbumConfiguration albumConfiguration) + : ICommandHandler> { - var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); - if (found.IsError) - return found.Errors; - - var album = found.Value; - var title = request.Title.Trim(); - - album.Title = title; - album.NormalizedTitle = TextNormalizer.Normalize(title); - album.Description = request.Description; - album.ReleaseYear = request.ReleaseYear; - - if (request.NewPictureIntentId is { } intentId) + public async ValueTask> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken) { - var prepared = await PictureSourceChange.PrepareAsync( - uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken); - if (prepared.IsError) - return prepared.Errors; + var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; - var picture = prepared.Value; - album.Pictures = picture.Pictures; + var album = found.Value; + var title = request.Title.Trim(); - await eventBus.PublishAsync( - new UpdateAlbumPictureSourceEvent( - album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), - cancellationToken); + album.Title = title; + album.NormalizedTitle = TextNormalizer.Normalize(title); + album.Description = request.Description; + album.ReleaseYear = request.ReleaseYear; + + if (request.NewPictureIntentId is { } intentId) + { + var prepared = await PictureSourceChange.PrepareAsync( + uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken); + if (prepared.IsError) + return prepared.Errors; + + var picture = prepared.Value; + album.Pictures = picture.Pictures; + + await eventBus.PublishAsync( + new UpdateAlbumPictureSourceEvent( + album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), + cancellationToken); + } + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Updated album {AlbumId}", album.Id); + + return await database.Albums + .AsNoTracking() + .Where(a => a.Id == album.Id) + .SelectResponse() + .SingleAsync(cancellationToken); } - - await database.SaveChangesAsync(cancellationToken); - - logger.LogInformation("Updated album {AlbumId}", album.Id); - - return await database.Albums - .AsNoTracking() - .Where(a => a.Id == album.Id) - .SelectResponse() - .SingleAsync(cancellationToken); } } diff --git a/backend/Core/Musify.Application/ApplicationDependencyInjection.cs b/backend/Core/Musify.Application/ApplicationDependencyInjection.cs index 8e22f82..a96334a 100644 --- a/backend/Core/Musify.Application/ApplicationDependencyInjection.cs +++ b/backend/Core/Musify.Application/ApplicationDependencyInjection.cs @@ -3,26 +3,27 @@ using Microsoft.Extensions.DependencyInjection; using Musify.Application.Configuration; using Musify.Application.Services; -namespace Musify.Application; - -public static class ApplicationDependencyInjection +namespace Musify.Application { - public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration) + public static class ApplicationDependencyInjection { - services.AddMediator(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration) + { + services.AddMediator(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); - services.AddValidatedOptions(configuration); - services.AddValidatedOptions(configuration); - services.AddValidatedOptions(configuration); - services.AddValidatedOptions(configuration); - services.AddValidatedOptions(configuration); - services.AddValidatedOptions(configuration); - services.AddValidatedOptions(configuration); - services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); + services.AddValidatedOptions(configuration); - return services; + return services; + } } } diff --git a/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs b/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs index c139a4d..88db57b 100644 --- a/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs @@ -1,14 +1,15 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Application.Configuration; - -public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfiguration +namespace Musify.Application.Configuration { - public static string SectionName => "Album"; + public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfiguration + { + public static string SectionName => "Album"; - [Required] - public PictureRoutes Routes { get; set; } = new() { ParentFolder = "Albums" }; + [Required] + public PictureRoutes Routes { get; set; } = new() { ParentFolder = "Albums" }; - [Required] - public PictureSizes PicturesSizes { get; set; } = new(); + [Required] + public PictureSizes PicturesSizes { get; set; } = new(); + } } diff --git a/backend/Core/Musify.Application/Configuration/ApplicationStorageConfiguration.cs b/backend/Core/Musify.Application/Configuration/ApplicationStorageConfiguration.cs index f6fedc4..199cc5e 100644 --- a/backend/Core/Musify.Application/Configuration/ApplicationStorageConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/ApplicationStorageConfiguration.cs @@ -1,12 +1,13 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Application.Configuration; - -public class ApplicationStorageConfiguration : IConfigurationOptions +namespace Musify.Application.Configuration { - public static string SectionName => "ApplicationStorage"; + public class ApplicationStorageConfiguration : IConfigurationOptions + { + public static string SectionName => "ApplicationStorage"; - [Required] - [StringLength(128, MinimumLength = 1)] - public required string Bucket { get; set; } + [Required] + [StringLength(128, MinimumLength = 1)] + public required string Bucket { get; set; } + } } diff --git a/backend/Core/Musify.Application/Configuration/ConfigurationDependencyInjection.cs b/backend/Core/Musify.Application/Configuration/ConfigurationDependencyInjection.cs index aa7fa1a..ff58e2f 100644 --- a/backend/Core/Musify.Application/Configuration/ConfigurationDependencyInjection.cs +++ b/backend/Core/Musify.Application/Configuration/ConfigurationDependencyInjection.cs @@ -2,28 +2,29 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -namespace Musify.Application.Configuration; - -public static class ConfigurationDependencyInjection +namespace Musify.Application.Configuration { - public static IServiceCollection AddValidatedOptions(this IServiceCollection services, IConfiguration configuration) - where T : class, IConfigurationOptions => - services.AddValidatedOptions(configuration, T.SectionName); - - public static IServiceCollection AddValidatedOptions( - this IServiceCollection services, - IConfiguration configuration, - string sectionName) - where T : class + public static class ConfigurationDependencyInjection { - services - .AddOptionsWithValidateOnStart() - .Bind(configuration.GetRequiredSection(sectionName)) - .ValidateDataAnnotations(); + public static IServiceCollection AddValidatedOptions(this IServiceCollection services, IConfiguration configuration) + where T : class, IConfigurationOptions => + services.AddValidatedOptions(configuration, T.SectionName); - services.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService>().Value); + public static IServiceCollection AddValidatedOptions( + this IServiceCollection services, + IConfiguration configuration, + string sectionName) + where T : class + { + services + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(sectionName)) + .ValidateDataAnnotations(); - return services; + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + return services; + } } } diff --git a/backend/Core/Musify.Application/Configuration/GetPlaybackPublicConfig.cs b/backend/Core/Musify.Application/Configuration/GetPlaybackPublicConfig.cs index e731ae2..8b96121 100644 --- a/backend/Core/Musify.Application/Configuration/GetPlaybackPublicConfig.cs +++ b/backend/Core/Musify.Application/Configuration/GetPlaybackPublicConfig.cs @@ -1,16 +1,17 @@ using Mediator; using Musify.Application.Configuration.Responses; -namespace Musify.Application.Configuration; - - -public record GetPlaybackPublicConfigQuery : IQuery; - -public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration) - : IQueryHandler +namespace Musify.Application.Configuration { - public ValueTask Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) => - ValueTask.FromResult(new PlaybackPublicConfigResponse( - playbackConfiguration.AllowAnonymousListening, - playbackConfiguration.AnonymousFragmentSeconds)); + + public record GetPlaybackPublicConfigQuery : IQuery; + + public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration) + : IQueryHandler + { + public ValueTask Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) => + ValueTask.FromResult(new PlaybackPublicConfigResponse( + playbackConfiguration.AllowAnonymousListening, + playbackConfiguration.AnonymousFragmentSeconds)); + } } diff --git a/backend/Core/Musify.Application/Configuration/IConfigurationOptions.cs b/backend/Core/Musify.Application/Configuration/IConfigurationOptions.cs index 3864320..8bd3ccf 100644 --- a/backend/Core/Musify.Application/Configuration/IConfigurationOptions.cs +++ b/backend/Core/Musify.Application/Configuration/IConfigurationOptions.cs @@ -1,6 +1,7 @@ -namespace Musify.Application.Configuration; - -public interface IConfigurationOptions +namespace Musify.Application.Configuration { - public static abstract string SectionName { get; } + public interface IConfigurationOptions + { + public static abstract string SectionName { get; } + } } diff --git a/backend/Core/Musify.Application/Configuration/MixConfiguration.cs b/backend/Core/Musify.Application/Configuration/MixConfiguration.cs index bb16860..1a71d6e 100644 --- a/backend/Core/Musify.Application/Configuration/MixConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/MixConfiguration.cs @@ -1,11 +1,12 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Application.Configuration; - -public class MixConfiguration : IConfigurationOptions +namespace Musify.Application.Configuration { - public static string SectionName => "Mix"; + public class MixConfiguration : IConfigurationOptions + { + public static string SectionName => "Mix"; - [Range(4, 100)] - public int ItemsPerMix { get; set; } = 12; + [Range(4, 100)] + public int ItemsPerMix { get; set; } = 12; + } } diff --git a/backend/Core/Musify.Application/Configuration/Pictures.cs b/backend/Core/Musify.Application/Configuration/Pictures.cs index 5e71035..08235a1 100644 --- a/backend/Core/Musify.Application/Configuration/Pictures.cs +++ b/backend/Core/Musify.Application/Configuration/Pictures.cs @@ -2,90 +2,91 @@ using System.ComponentModel.DataAnnotations; using System.Globalization; using Musify.Application.Shared; -namespace Musify.Application.Configuration; - -public enum PictureSize +namespace Musify.Application.Configuration { - Small, - Medium, - Large -} - -public class PictureRoutes -{ - [Required] - public string UploadsFolder { get; set; } = "uploads"; - - [Required] - public string ParentFolder { get; set; } = string.Empty; - - [Required] - public string OriginalPicturesFolder { get; set; } = "OriginalPictures"; - - [Required] - public string SmallPicturesFolder { get; set; } = "SmallPictures"; - - [Required] - public string MediumPicturesFolder { get; set; } = "MediumPictures"; - - [Required] - public string LargePicturesFolder { get; set; } = "LargePictures"; - - public string FolderPath(PictureSize size) => StorageKey.Combine(ParentFolder, size switch + public enum PictureSize { - PictureSize.Small => SmallPicturesFolder, - PictureSize.Medium => MediumPicturesFolder, - PictureSize.Large => LargePicturesFolder, - _ => throw new ArgumentOutOfRangeException(nameof(size), size, null) - }); + Small, + Medium, + Large + } - public string BuildPicturePath(PictureSize size, string pictureName) => StorageKey.Combine(FolderPath(size), pictureName); + public class PictureRoutes + { + [Required] + public string UploadsFolder { get; set; } = "uploads"; - public string BuildOriginalPicturePath(long userId, string pictureName) => - StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalPicturesFolder, pictureName); + [Required] + public string ParentFolder { get; set; } = string.Empty; - public string BuildTempPath(string tempRootPrefix, long userId, string objectName) => - StorageKey.Combine(tempRootPrefix, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, objectName); -} - -public class PictureSizes -{ - [Range(1, 1024)] - public int SmallWidth { get; set; } = 128; - - [Range(1, 1024)] - public int SmallHeight { get; set; } = 128; - - [Range(1, 1024)] - public int MediumWidth { get; set; } = 256; - - [Range(1, 1024)] - public int MediumHeight { get; set; } = 256; - - [Range(1, 1024)] - public int LargeWidth { get; set; } = 512; - - [Range(1, 1024)] - public int LargeHeight { get; set; } = 512; - - public ImageSizes ToImageSizes(PictureRoutes routes) => new( - new ImageSize(routes.FolderPath(PictureSize.Small), SmallWidth, SmallHeight), - new ImageSize(routes.FolderPath(PictureSize.Medium), MediumWidth, MediumHeight), - new ImageSize(routes.FolderPath(PictureSize.Large), LargeWidth, LargeHeight)); -} - -public interface IPictureOwnerConfiguration -{ - public PictureRoutes Routes { get; } - - public PictureSizes PicturesSizes { get; } -} - -public static class PictureSizeParser -{ - /// Parses the size query value; anything unknown means . - public static PictureSize Parse(string? value) => - Enum.TryParse(value, ignoreCase: true, out var size) && Enum.IsDefined(size) - ? size - : PictureSize.Medium; + [Required] + public string OriginalPicturesFolder { get; set; } = "OriginalPictures"; + + [Required] + public string SmallPicturesFolder { get; set; } = "SmallPictures"; + + [Required] + public string MediumPicturesFolder { get; set; } = "MediumPictures"; + + [Required] + public string LargePicturesFolder { get; set; } = "LargePictures"; + + public string FolderPath(PictureSize size) => StorageKey.Combine(ParentFolder, size switch + { + PictureSize.Small => SmallPicturesFolder, + PictureSize.Medium => MediumPicturesFolder, + PictureSize.Large => LargePicturesFolder, + _ => throw new ArgumentOutOfRangeException(nameof(size), size, null) + }); + + public string BuildPicturePath(PictureSize size, string pictureName) => StorageKey.Combine(FolderPath(size), pictureName); + + public string BuildOriginalPicturePath(long userId, string pictureName) => + StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalPicturesFolder, pictureName); + + public string BuildTempPath(string tempRootPrefix, long userId, string objectName) => + StorageKey.Combine(tempRootPrefix, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, objectName); + } + + public class PictureSizes + { + [Range(1, 1024)] + public int SmallWidth { get; set; } = 128; + + [Range(1, 1024)] + public int SmallHeight { get; set; } = 128; + + [Range(1, 1024)] + public int MediumWidth { get; set; } = 256; + + [Range(1, 1024)] + public int MediumHeight { get; set; } = 256; + + [Range(1, 1024)] + public int LargeWidth { get; set; } = 512; + + [Range(1, 1024)] + public int LargeHeight { get; set; } = 512; + + public ImageSizes ToImageSizes(PictureRoutes routes) => new( + new ImageSize(routes.FolderPath(PictureSize.Small), SmallWidth, SmallHeight), + new ImageSize(routes.FolderPath(PictureSize.Medium), MediumWidth, MediumHeight), + new ImageSize(routes.FolderPath(PictureSize.Large), LargeWidth, LargeHeight)); + } + + public interface IPictureOwnerConfiguration + { + public PictureRoutes Routes { get; } + + public PictureSizes PicturesSizes { get; } + } + + public static class PictureSizeParser + { + /// Parses the size query value; anything unknown means . + public static PictureSize Parse(string? value) => + Enum.TryParse(value, ignoreCase: true, out var size) && Enum.IsDefined(size) + ? size + : PictureSize.Medium; + } } diff --git a/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs b/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs index 10385a9..7550d1e 100644 --- a/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs @@ -1,14 +1,15 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Application.Configuration; - -public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfiguration +namespace Musify.Application.Configuration { - public static string SectionName => "PlayList"; + public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfiguration + { + public static string SectionName => "PlayList"; - [Required] - public PictureRoutes Routes { get; set; } = new() { ParentFolder = "PlayLists" }; + [Required] + public PictureRoutes Routes { get; set; } = new() { ParentFolder = "PlayLists" }; - [Required] - public PictureSizes PicturesSizes { get; set; } = new(); + [Required] + public PictureSizes PicturesSizes { get; set; } = new(); + } } diff --git a/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs b/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs index 2fac12b..bf0f0c2 100644 --- a/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs @@ -1,17 +1,18 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Application.Configuration; - - -public sealed class PlaybackConfiguration : IConfigurationOptions +namespace Musify.Application.Configuration { - public static string SectionName => "Playback"; - public bool AllowAnonymousListening { get; set; } + public sealed class PlaybackConfiguration : IConfigurationOptions + { + public static string SectionName => "Playback"; - [Range(0, 3600)] - public int AnonymousFragmentSeconds { get; set; } + public bool AllowAnonymousListening { get; set; } - [Range(1, int.MaxValue)] - public int EstimatedAudioBytesPerSecond { get; set; } = 16_000; + [Range(0, 3600)] + public int AnonymousFragmentSeconds { get; set; } + + [Range(1, int.MaxValue)] + public int EstimatedAudioBytesPerSecond { get; set; } = 16_000; + } } diff --git a/backend/Core/Musify.Application/Configuration/Responses/PlaybackPublicConfigResponse.cs b/backend/Core/Musify.Application/Configuration/Responses/PlaybackPublicConfigResponse.cs index c1083a3..90a92b9 100644 --- a/backend/Core/Musify.Application/Configuration/Responses/PlaybackPublicConfigResponse.cs +++ b/backend/Core/Musify.Application/Configuration/Responses/PlaybackPublicConfigResponse.cs @@ -1,4 +1,5 @@ -namespace Musify.Application.Configuration.Responses; +namespace Musify.Application.Configuration.Responses +{ - -public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds); + public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds); +} diff --git a/backend/Core/Musify.Application/Configuration/StreamGatewayConfiguration.cs b/backend/Core/Musify.Application/Configuration/StreamGatewayConfiguration.cs index 3c9ecb8..488536d 100644 --- a/backend/Core/Musify.Application/Configuration/StreamGatewayConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/StreamGatewayConfiguration.cs @@ -1,15 +1,16 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Application.Configuration; - -public class StreamGatewayConfiguration : IConfigurationOptions +namespace Musify.Application.Configuration { - public static string SectionName => "StreamGateway"; + public class StreamGatewayConfiguration : IConfigurationOptions + { + public static string SectionName => "StreamGateway"; - [Url] - [Required] - public required string PublicBaseUrl { get; set; } + [Url] + [Required] + public required string PublicBaseUrl { get; set; } - [Required] - public string AudioFileName { get; set; } = "audio.m4a"; + [Required] + public string AudioFileName { get; set; } = "audio.m4a"; + } } diff --git a/backend/Core/Musify.Application/Configuration/TrackConfiguration.cs b/backend/Core/Musify.Application/Configuration/TrackConfiguration.cs index 80b8df3..b569b1f 100644 --- a/backend/Core/Musify.Application/Configuration/TrackConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/TrackConfiguration.cs @@ -2,55 +2,56 @@ using System.ComponentModel.DataAnnotations; using System.Globalization; using Musify.Application.Shared; -namespace Musify.Application.Configuration; - -public class TrackConfiguration : IConfigurationOptions, IPictureOwnerConfiguration +namespace Musify.Application.Configuration { - public static string SectionName => "Track"; - - [Required] - public TrackRoutes Routes { get; set; } = new(); - - [Required] - public PictureSizes PicturesSizes { get; set; } = new(); - - PictureRoutes IPictureOwnerConfiguration.Routes => Routes; -} - -public class TrackRoutes : PictureRoutes -{ - public TrackRoutes() + public class TrackConfiguration : IConfigurationOptions, IPictureOwnerConfiguration { - ParentFolder = "Tracks"; + public static string SectionName => "Track"; + + [Required] + public TrackRoutes Routes { get; set; } = new(); + + [Required] + public PictureSizes PicturesSizes { get; set; } = new(); + + PictureRoutes IPictureOwnerConfiguration.Routes => Routes; } - [Required] - public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp"; + public class TrackRoutes : PictureRoutes + { + public TrackRoutes() + { + ParentFolder = "Tracks"; + } - [Required] - public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp"; + [Required] + public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp"; - [Required] - public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp"; + [Required] + public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp"; - [Required] - public string PresetLargePicture { get; set; } = "PresetLargePicture.webp"; + [Required] + public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp"; - [Required] - public string OriginalAudiosFolder { get; set; } = "OriginalAudios"; + [Required] + public string PresetLargePicture { get; set; } = "PresetLargePicture.webp"; - [Required] - public string ProcessedAudiosFolder { get; set; } = "ProcessedAudios"; + [Required] + public string OriginalAudiosFolder { get; set; } = "OriginalAudios"; - public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture); + [Required] + public string ProcessedAudiosFolder { get; set; } = "ProcessedAudios"; - public string PresetMediumPicturePath => StorageKey.Combine(ParentFolder, PresetMediumPicture); + public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture); - public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture); + public string PresetMediumPicturePath => StorageKey.Combine(ParentFolder, PresetMediumPicture); - public string BuildOriginalAudioPath(long userId, string audioName) => - StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalAudiosFolder, audioName); + public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture); - public string BuildProcessedAudioPath(string folderName) => - StorageKey.Combine(ParentFolder, ProcessedAudiosFolder, folderName); + public string BuildOriginalAudioPath(long userId, string audioName) => + StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalAudiosFolder, audioName); + + public string BuildProcessedAudioPath(string folderName) => + StorageKey.Combine(ParentFolder, ProcessedAudiosFolder, folderName); + } } diff --git a/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs b/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs index 3b72185..eb68c90 100644 --- a/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs @@ -1,41 +1,42 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Application.Configuration; - -public class UploadIntentConfiguration : IConfigurationOptions +namespace Musify.Application.Configuration { - public static string SectionName => "UploadIntent"; + public class UploadIntentConfiguration : IConfigurationOptions + { + public static string SectionName => "UploadIntent"; - [Range(10, 3600)] - public int UploadUrlExpiresInSeconds { get; set; } = 120; + [Range(10, 3600)] + public int UploadUrlExpiresInSeconds { get; set; } = 120; - [Range(1, 100)] - public int MaxActiveUploadIntentsPerUser { get; set; } = 5; + [Range(1, 100)] + public int MaxActiveUploadIntentsPerUser { get; set; } = 5; - [Range(1, long.MaxValue)] - public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024; + [Range(1, long.MaxValue)] + public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024; - [Range(1, long.MaxValue)] - public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024; + [Range(1, long.MaxValue)] + public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024; - [Range(1, long.MaxValue)] - public long DefaultExpectedPictureSizeBytes { get; set; } = 10L * 1024 * 1024; + [Range(1, long.MaxValue)] + public long DefaultExpectedPictureSizeBytes { get; set; } = 10L * 1024 * 1024; - [Range(1, long.MaxValue)] - public long DefaultExpectedAudioSizeBytes { get; set; } = 100L * 1024 * 1024; + [Range(1, long.MaxValue)] + public long DefaultExpectedAudioSizeBytes { get; set; } = 100L * 1024 * 1024; - [Range(30, 86400)] - public int ExpirationJobIntervalSeconds { get; set; } = 300; + [Range(30, 86400)] + public int ExpirationJobIntervalSeconds { get; set; } = 300; - [Range(0, 365)] - public int ExpiredIntentsRetentionDays { get; set; } = 7; + [Range(0, 365)] + public int ExpiredIntentsRetentionDays { get; set; } = 7; - [Required, MinLength(1)] - public string TempRootPrefix { get; set; } = "temp"; + [Required, MinLength(1)] + public string TempRootPrefix { get; set; } = "temp"; - [Range(1, 30)] - public int TempUploadsRetentionDays { get; set; } = 3; + [Range(1, 30)] + public int TempUploadsRetentionDays { get; set; } = 3; - [Range(60, 86400)] - public int TempCleanupJobIntervalSeconds { get; set; } = 3600; + [Range(60, 86400)] + public int TempCleanupJobIntervalSeconds { get; set; } = 3600; + } } diff --git a/backend/Core/Musify.Application/Contracts/AudioTranscodeResult.cs b/backend/Core/Musify.Application/Contracts/AudioTranscodeResult.cs index 85e45e4..43902f1 100644 --- a/backend/Core/Musify.Application/Contracts/AudioTranscodeResult.cs +++ b/backend/Core/Musify.Application/Contracts/AudioTranscodeResult.cs @@ -1,3 +1,4 @@ -namespace Musify.Application.Contracts; - -public record AudioTranscodeResult(int StatusCode, TimeSpan Duration); +namespace Musify.Application.Contracts +{ + public record AudioTranscodeResult(int StatusCode, TimeSpan Duration); +} diff --git a/backend/Core/Musify.Application/Contracts/IAudioTranscoderService.cs b/backend/Core/Musify.Application/Contracts/IAudioTranscoderService.cs index 29f8b81..e4f56a0 100644 --- a/backend/Core/Musify.Application/Contracts/IAudioTranscoderService.cs +++ b/backend/Core/Musify.Application/Contracts/IAudioTranscoderService.cs @@ -1,6 +1,7 @@ -namespace Musify.Application.Contracts; - -public interface IAudioTranscoderService +namespace Musify.Application.Contracts { - public Task TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken); + public interface IAudioTranscoderService + { + public Task TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken); + } } diff --git a/backend/Core/Musify.Application/Contracts/IDatabase.cs b/backend/Core/Musify.Application/Contracts/IDatabase.cs index 89e0964..840a894 100644 --- a/backend/Core/Musify.Application/Contracts/IDatabase.cs +++ b/backend/Core/Musify.Application/Contracts/IDatabase.cs @@ -2,41 +2,42 @@ using ErrorOr; using Microsoft.EntityFrameworkCore; using Musify.Domain.Entities; -namespace Musify.Application.Contracts; - -public interface IDatabase +namespace Musify.Application.Contracts { - public DbSet Users { get; } + public interface IDatabase + { + public DbSet Users { get; } - public DbSet PlayLists { get; } + public DbSet PlayLists { get; } - public DbSet Albums { get; } + public DbSet Albums { get; } - public DbSet AlbumHasTracks { get; } + public DbSet AlbumHasTracks { get; } - public DbSet Tracks { get; } + public DbSet Tracks { get; } - public DbSet PlayListHasTracks { get; } + public DbSet PlayListHasTracks { get; } - public DbSet Mixes { get; } + public DbSet Mixes { get; } - public DbSet MixItems { get; } + public DbSet MixItems { get; } - public DbSet UploadIntents { get; } + public DbSet UploadIntents { get; } - public DbSet ListeningHistories { get; } + public DbSet ListeningHistories { get; } - public DbSet TrackLikes { get; } + public DbSet TrackLikes { get; } - public DbSet TrackTags { get; } + public DbSet TrackTags { get; } - public DbSet UserFollows { get; } + public DbSet UserFollows { get; } - public Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); + public Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); - public DbSet Set() where TEntity : class; + public DbSet Set() where TEntity : class; - public Task SaveChangesAsync(CancellationToken cancellationToken); + public Task SaveChangesAsync(CancellationToken cancellationToken); - public Task> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken); + public Task> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken); + } } diff --git a/backend/Core/Musify.Application/Contracts/IDatabaseTransaction.cs b/backend/Core/Musify.Application/Contracts/IDatabaseTransaction.cs index 5e71827..1026ebb 100644 --- a/backend/Core/Musify.Application/Contracts/IDatabaseTransaction.cs +++ b/backend/Core/Musify.Application/Contracts/IDatabaseTransaction.cs @@ -1,6 +1,7 @@ -namespace Musify.Application.Contracts; - -public interface IDatabaseTransaction : IAsyncDisposable +namespace Musify.Application.Contracts { - public Task CommitAsync(CancellationToken cancellationToken = default); + public interface IDatabaseTransaction : IAsyncDisposable + { + public Task CommitAsync(CancellationToken cancellationToken = default); + } } diff --git a/backend/Core/Musify.Application/Contracts/IEventBus.cs b/backend/Core/Musify.Application/Contracts/IEventBus.cs index bd9182a..0f26bc5 100644 --- a/backend/Core/Musify.Application/Contracts/IEventBus.cs +++ b/backend/Core/Musify.Application/Contracts/IEventBus.cs @@ -1,6 +1,7 @@ -namespace Musify.Application.Contracts; - -public interface IEventBus +namespace Musify.Application.Contracts { - public Task PublishAsync(T message, CancellationToken cancellationToken) where T : class; + public interface IEventBus + { + public Task PublishAsync(T message, CancellationToken cancellationToken) where T : class; + } } diff --git a/backend/Core/Musify.Application/Contracts/IPictureService.cs b/backend/Core/Musify.Application/Contracts/IPictureService.cs index 3e74bf4..b5958d8 100644 --- a/backend/Core/Musify.Application/Contracts/IPictureService.cs +++ b/backend/Core/Musify.Application/Contracts/IPictureService.cs @@ -1,6 +1,7 @@ -namespace Musify.Application.Contracts; - -public interface IPictureService +namespace Musify.Application.Contracts { - public Task ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken); + public interface IPictureService + { + public Task ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken); + } } diff --git a/backend/Core/Musify.Application/Contracts/IStorageService.cs b/backend/Core/Musify.Application/Contracts/IStorageService.cs index 90a8601..dfd37ab 100644 --- a/backend/Core/Musify.Application/Contracts/IStorageService.cs +++ b/backend/Core/Musify.Application/Contracts/IStorageService.cs @@ -1,24 +1,25 @@ -namespace Musify.Application.Contracts; - -public interface IStorageService +namespace Musify.Application.Contracts { - /// Opens an object for reading, or returns null when it does not exist. - public Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken); + public interface IStorageService + { + /// Opens an object for reading, or returns null when it does not exist. + public Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken); - public Task HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken); + public Task HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken); - public Task GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, bool preventOverwrite = true, CancellationToken cancellationToken = default); + public Task GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, bool preventOverwrite = true, CancellationToken cancellationToken = default); - public Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken); + public Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken); - public Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken); + public Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken); - public Task> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken); + public Task> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken); - public Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken); + public Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken); - public Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, - CancellationToken cancellationToken); + public Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, + CancellationToken cancellationToken); - public IAsyncEnumerable ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken); + public IAsyncEnumerable ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken); + } } diff --git a/backend/Core/Musify.Application/Contracts/IStreamTicketService.cs b/backend/Core/Musify.Application/Contracts/IStreamTicketService.cs index 2668555..c4d9118 100644 --- a/backend/Core/Musify.Application/Contracts/IStreamTicketService.cs +++ b/backend/Core/Musify.Application/Contracts/IStreamTicketService.cs @@ -1,8 +1,9 @@ -namespace Musify.Application.Contracts; - -public interface IStreamTicketService +namespace Musify.Application.Contracts { - public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null); -} + public interface IStreamTicketService + { + public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null); + } -public record StreamTicket(string Token, int ExpiresInSeconds); + public record StreamTicket(string Token, int ExpiresInSeconds); +} diff --git a/backend/Core/Musify.Application/Contracts/ObjectMetaData.cs b/backend/Core/Musify.Application/Contracts/ObjectMetaData.cs index 0211261..6406463 100644 --- a/backend/Core/Musify.Application/Contracts/ObjectMetaData.cs +++ b/backend/Core/Musify.Application/Contracts/ObjectMetaData.cs @@ -1,3 +1,4 @@ -namespace Musify.Application.Contracts; - -public record ObjectMetaData(string ContentType, long ContentLength); +namespace Musify.Application.Contracts +{ + public record ObjectMetaData(string ContentType, long ContentLength); +} diff --git a/backend/Core/Musify.Application/Contracts/StorageObject.cs b/backend/Core/Musify.Application/Contracts/StorageObject.cs index 9572137..2ec4d45 100644 --- a/backend/Core/Musify.Application/Contracts/StorageObject.cs +++ b/backend/Core/Musify.Application/Contracts/StorageObject.cs @@ -1,3 +1,4 @@ -namespace Musify.Application.Contracts; - -public record StorageObject(string Key, DateTime LastModifiedUtc); +namespace Musify.Application.Contracts +{ + public record StorageObject(string Key, DateTime LastModifiedUtc); +} diff --git a/backend/Core/Musify.Application/Events/AlbumPictureProcessed.cs b/backend/Core/Musify.Application/Events/AlbumPictureProcessed.cs index 99b2cb6..73db1d3 100644 --- a/backend/Core/Musify.Application/Events/AlbumPictureProcessed.cs +++ b/backend/Core/Musify.Application/Events/AlbumPictureProcessed.cs @@ -1,3 +1,4 @@ -namespace Musify.Application.Events; - -public record AlbumPictureProcessed(Guid AlbumId); +namespace Musify.Application.Events +{ + public record AlbumPictureProcessed(Guid AlbumId); +} diff --git a/backend/Core/Musify.Application/Events/AlbumPictureProcessingFailed.cs b/backend/Core/Musify.Application/Events/AlbumPictureProcessingFailed.cs index f659536..24f4cc5 100644 --- a/backend/Core/Musify.Application/Events/AlbumPictureProcessingFailed.cs +++ b/backend/Core/Musify.Application/Events/AlbumPictureProcessingFailed.cs @@ -1,3 +1,4 @@ -namespace Musify.Application.Events; - -public record AlbumPictureProcessingFailed(Guid AlbumId); +namespace Musify.Application.Events +{ + public record AlbumPictureProcessingFailed(Guid AlbumId); +} diff --git a/backend/Core/Musify.Application/Events/AlbumProcessingFailed.cs b/backend/Core/Musify.Application/Events/AlbumProcessingFailed.cs index 4640faf..5ee8895 100644 --- a/backend/Core/Musify.Application/Events/AlbumProcessingFailed.cs +++ b/backend/Core/Musify.Application/Events/AlbumProcessingFailed.cs @@ -1,6 +1,7 @@ -namespace Musify.Application.Events; - -public record AlbumProcessingFailed( - Guid AlbumId, - string? Bucket, - string? PictureKey); +namespace Musify.Application.Events +{ + public record AlbumProcessingFailed( + Guid AlbumId, + string? Bucket, + string? PictureKey); +} diff --git a/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs b/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs index 01dada3..f048259 100644 --- a/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs +++ b/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs @@ -1,11 +1,12 @@ using Musify.Application.Shared; -namespace Musify.Application.Events; - -public record CreateAlbumResourcesEvent( - Guid AlbumId, - Guid PictureIntentId, - string Bucket, - string PictureSourceKey, - string PictureDestinationKey, - ImageSizes Sizes); +namespace Musify.Application.Events +{ + public record CreateAlbumResourcesEvent( + Guid AlbumId, + Guid PictureIntentId, + string Bucket, + string PictureSourceKey, + string PictureDestinationKey, + ImageSizes Sizes); +} diff --git a/backend/Core/Musify.Application/Events/CreatePlayListResourcesEvent.cs b/backend/Core/Musify.Application/Events/CreatePlayListResourcesEvent.cs index 9ad6955..faf34b9 100644 --- a/backend/Core/Musify.Application/Events/CreatePlayListResourcesEvent.cs +++ b/backend/Core/Musify.Application/Events/CreatePlayListResourcesEvent.cs @@ -1,11 +1,12 @@ using Musify.Application.Shared; -namespace Musify.Application.Events; - -public record CreatePlayListResourcesEvent( - Guid PlayListId, - Guid PictureIntentId, - string Bucket, - string PictureSourceKey, - string PictureDestinationKey, - ImageSizes Sizes); +namespace Musify.Application.Events +{ + public record CreatePlayListResourcesEvent( + Guid PlayListId, + Guid PictureIntentId, + string Bucket, + string PictureSourceKey, + string PictureDestinationKey, + ImageSizes Sizes); +} diff --git a/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs b/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs index baa9315..6a78fd5 100644 --- a/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs +++ b/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs @@ -1,15 +1,16 @@ using Musify.Application.Shared; -namespace Musify.Application.Events; - -public record CreateTrackResourcesEvent( - Guid TrackId, - Guid PictureIntentId, - Guid AudioIntentId, - string Bucket, - string PictureSourceKey, - string PictureDestinationKey, - string AudioSourceKey, - string AudioDestinationKey, - string AudioProcessedFolderKey, - ImageSizes Sizes); +namespace Musify.Application.Events +{ + public record CreateTrackResourcesEvent( + Guid TrackId, + Guid PictureIntentId, + Guid AudioIntentId, + string Bucket, + string PictureSourceKey, + string PictureDestinationKey, + string AudioSourceKey, + string AudioDestinationKey, + string AudioProcessedFolderKey, + ImageSizes Sizes); +} diff --git a/backend/Core/Musify.Application/Events/DeleteAlbumEvent.cs b/backend/Core/Musify.Application/Events/DeleteAlbumEvent.cs index ab5cfd5..ebe52d0 100644 --- a/backend/Core/Musify.Application/Events/DeleteAlbumEvent.cs +++ b/backend/Core/Musify.Application/Events/DeleteAlbumEvent.cs @@ -1,5 +1,6 @@ -namespace Musify.Application.Events; - -public record DeleteAlbumEvent( - Guid AlbumId, - long UserId); +namespace Musify.Application.Events +{ + public record DeleteAlbumEvent( + Guid AlbumId, + long UserId); +} diff --git a/backend/Core/Musify.Application/Events/DeletePlayListEvent.cs b/backend/Core/Musify.Application/Events/DeletePlayListEvent.cs index 40d589b..06a7a2c 100644 --- a/backend/Core/Musify.Application/Events/DeletePlayListEvent.cs +++ b/backend/Core/Musify.Application/Events/DeletePlayListEvent.cs @@ -1,5 +1,6 @@ -namespace Musify.Application.Events; - -public record DeletePlayListEvent( - Guid PlayListId, - long UserId); +namespace Musify.Application.Events +{ + public record DeletePlayListEvent( + Guid PlayListId, + long UserId); +} diff --git a/backend/Core/Musify.Application/Events/DeleteTrackEvent.cs b/backend/Core/Musify.Application/Events/DeleteTrackEvent.cs index 7c78c6f..8fb6923 100644 --- a/backend/Core/Musify.Application/Events/DeleteTrackEvent.cs +++ b/backend/Core/Musify.Application/Events/DeleteTrackEvent.cs @@ -1,5 +1,6 @@ -namespace Musify.Application.Events; - -public record DeleteTrackEvent( - Guid TrackId, - long UserId); +namespace Musify.Application.Events +{ + public record DeleteTrackEvent( + Guid TrackId, + long UserId); +} diff --git a/backend/Core/Musify.Application/Events/PlayListPictureProcessed.cs b/backend/Core/Musify.Application/Events/PlayListPictureProcessed.cs index ddb68e7..74f03f2 100644 --- a/backend/Core/Musify.Application/Events/PlayListPictureProcessed.cs +++ b/backend/Core/Musify.Application/Events/PlayListPictureProcessed.cs @@ -1,3 +1,4 @@ -namespace Musify.Application.Events; - -public record PlayListPictureProcessed(Guid PlayListId); +namespace Musify.Application.Events +{ + public record PlayListPictureProcessed(Guid PlayListId); +} diff --git a/backend/Core/Musify.Application/Events/PlayListPictureProcessingFailed.cs b/backend/Core/Musify.Application/Events/PlayListPictureProcessingFailed.cs index 01e0b9c..661932c 100644 --- a/backend/Core/Musify.Application/Events/PlayListPictureProcessingFailed.cs +++ b/backend/Core/Musify.Application/Events/PlayListPictureProcessingFailed.cs @@ -1,3 +1,4 @@ -namespace Musify.Application.Events; - -public record PlayListPictureProcessingFailed(Guid PlayListId); +namespace Musify.Application.Events +{ + public record PlayListPictureProcessingFailed(Guid PlayListId); +} diff --git a/backend/Core/Musify.Application/Events/PlayListProcessingFailed.cs b/backend/Core/Musify.Application/Events/PlayListProcessingFailed.cs index 6c97a28..50a5054 100644 --- a/backend/Core/Musify.Application/Events/PlayListProcessingFailed.cs +++ b/backend/Core/Musify.Application/Events/PlayListProcessingFailed.cs @@ -1,6 +1,7 @@ -namespace Musify.Application.Events; - -public record PlayListProcessingFailed( - Guid PlayListId, - string? Bucket, - string? PictureKey); +namespace Musify.Application.Events +{ + public record PlayListProcessingFailed( + Guid PlayListId, + string? Bucket, + string? PictureKey); +} diff --git a/backend/Core/Musify.Application/Events/TrackAudioProcessed.cs b/backend/Core/Musify.Application/Events/TrackAudioProcessed.cs index 3bc35a3..9648bf2 100644 --- a/backend/Core/Musify.Application/Events/TrackAudioProcessed.cs +++ b/backend/Core/Musify.Application/Events/TrackAudioProcessed.cs @@ -1,3 +1,4 @@ -namespace Musify.Application.Events; - -public record TrackAudioProcessed(Guid TrackId); +namespace Musify.Application.Events +{ + public record TrackAudioProcessed(Guid TrackId); +} diff --git a/backend/Core/Musify.Application/Events/TrackAudioProcessingFailed.cs b/backend/Core/Musify.Application/Events/TrackAudioProcessingFailed.cs index 2757cdd..c531163 100644 --- a/backend/Core/Musify.Application/Events/TrackAudioProcessingFailed.cs +++ b/backend/Core/Musify.Application/Events/TrackAudioProcessingFailed.cs @@ -1,3 +1,4 @@ -namespace Musify.Application.Events; - -public record TrackAudioProcessingFailed(Guid TrackId); +namespace Musify.Application.Events +{ + public record TrackAudioProcessingFailed(Guid TrackId); +} diff --git a/backend/Core/Musify.Application/Events/TrackPictureProcessed.cs b/backend/Core/Musify.Application/Events/TrackPictureProcessed.cs index 0c39420..e42b30f 100644 --- a/backend/Core/Musify.Application/Events/TrackPictureProcessed.cs +++ b/backend/Core/Musify.Application/Events/TrackPictureProcessed.cs @@ -1,3 +1,4 @@ -namespace Musify.Application.Events; - -public record TrackPictureProcessed(Guid TrackId); +namespace Musify.Application.Events +{ + public record TrackPictureProcessed(Guid TrackId); +} diff --git a/backend/Core/Musify.Application/Events/TrackPictureProcessingFailed.cs b/backend/Core/Musify.Application/Events/TrackPictureProcessingFailed.cs index 593253a..df08dec 100644 --- a/backend/Core/Musify.Application/Events/TrackPictureProcessingFailed.cs +++ b/backend/Core/Musify.Application/Events/TrackPictureProcessingFailed.cs @@ -1,3 +1,4 @@ -namespace Musify.Application.Events; - -public record TrackPictureProcessingFailed(Guid TrackId); +namespace Musify.Application.Events +{ + public record TrackPictureProcessingFailed(Guid TrackId); +} diff --git a/backend/Core/Musify.Application/Events/TrackProcessingFailed.cs b/backend/Core/Musify.Application/Events/TrackProcessingFailed.cs index c7fb5c5..5234fe6 100644 --- a/backend/Core/Musify.Application/Events/TrackProcessingFailed.cs +++ b/backend/Core/Musify.Application/Events/TrackProcessingFailed.cs @@ -1,7 +1,8 @@ -namespace Musify.Application.Events; - -public record TrackProcessingFailed( - Guid TrackId, - string? Bucket, - string? PictureKey, - string? AudioKey); +namespace Musify.Application.Events +{ + public record TrackProcessingFailed( + Guid TrackId, + string? Bucket, + string? PictureKey, + string? AudioKey); +} diff --git a/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs b/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs index 6c8f43e..6ec428d 100644 --- a/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs @@ -1,9 +1,10 @@ using Musify.Application.Shared; -namespace Musify.Application.Events; - -public record UpdateAlbumPictureEvent( - Guid AlbumId, - string Bucket, - string SourceKey, - ImageSizes Sizes); +namespace Musify.Application.Events +{ + public record UpdateAlbumPictureEvent( + Guid AlbumId, + string Bucket, + string SourceKey, + ImageSizes Sizes); +} diff --git a/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs b/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs index 8fa33b8..92d727e 100644 --- a/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs @@ -1,11 +1,12 @@ using Musify.Application.Shared; -namespace Musify.Application.Events; - -public record UpdateAlbumPictureSourceEvent( - Guid AlbumId, - Guid PictureIntentId, - string Bucket, - string PictureSourceKey, - string PictureDestinationKey, - ImageSizes Sizes); +namespace Musify.Application.Events +{ + public record UpdateAlbumPictureSourceEvent( + Guid AlbumId, + Guid PictureIntentId, + string Bucket, + string PictureSourceKey, + string PictureDestinationKey, + ImageSizes Sizes); +} diff --git a/backend/Core/Musify.Application/Events/UpdatePlayListPictureEvent.cs b/backend/Core/Musify.Application/Events/UpdatePlayListPictureEvent.cs index 1b2d646..e28110d 100644 --- a/backend/Core/Musify.Application/Events/UpdatePlayListPictureEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdatePlayListPictureEvent.cs @@ -1,9 +1,10 @@ using Musify.Application.Shared; -namespace Musify.Application.Events; - -public record UpdatePlayListPictureEvent( - Guid PlayListId, - string Bucket, - string SourceKey, - ImageSizes Sizes); +namespace Musify.Application.Events +{ + public record UpdatePlayListPictureEvent( + Guid PlayListId, + string Bucket, + string SourceKey, + ImageSizes Sizes); +} diff --git a/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs b/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs index bba5c34..a5096fd 100644 --- a/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs @@ -1,11 +1,12 @@ using Musify.Application.Shared; -namespace Musify.Application.Events; - -public record UpdatePlayListPictureSourceEvent( - Guid PlayListId, - Guid PictureIntentId, - string Bucket, - string PictureSourceKey, - string PictureDestinationKey, - ImageSizes Sizes); +namespace Musify.Application.Events +{ + public record UpdatePlayListPictureSourceEvent( + Guid PlayListId, + Guid PictureIntentId, + string Bucket, + string PictureSourceKey, + string PictureDestinationKey, + ImageSizes Sizes); +} diff --git a/backend/Core/Musify.Application/Events/UpdateTrackAudioEvent.cs b/backend/Core/Musify.Application/Events/UpdateTrackAudioEvent.cs index a3b6b60..e1bc155 100644 --- a/backend/Core/Musify.Application/Events/UpdateTrackAudioEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdateTrackAudioEvent.cs @@ -1,8 +1,9 @@ -namespace Musify.Application.Events; - -public record UpdateTrackAudioEvent( - Guid TrackId, - string SourceBucket, - string SourceKey, - string DestinationBucket, - string DestinationFolderKey); +namespace Musify.Application.Events +{ + public record UpdateTrackAudioEvent( + Guid TrackId, + string SourceBucket, + string SourceKey, + string DestinationBucket, + string DestinationFolderKey); +} diff --git a/backend/Core/Musify.Application/Events/UpdateTrackPictureEvent.cs b/backend/Core/Musify.Application/Events/UpdateTrackPictureEvent.cs index 0c83c9e..e7ca8f4 100644 --- a/backend/Core/Musify.Application/Events/UpdateTrackPictureEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdateTrackPictureEvent.cs @@ -1,9 +1,10 @@ using Musify.Application.Shared; -namespace Musify.Application.Events; - -public record UpdateTrackPictureEvent( - Guid TrackId, - string Bucket, - string SourceKey, - ImageSizes Sizes); +namespace Musify.Application.Events +{ + public record UpdateTrackPictureEvent( + Guid TrackId, + string Bucket, + string SourceKey, + ImageSizes Sizes); +} diff --git a/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs b/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs index 694c9cd..f96eb11 100644 --- a/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs +++ b/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs @@ -2,23 +2,24 @@ using Mediator; using Musify.Application.Genres.Responses; using Musify.Domain.ValueObjects; -namespace Musify.Application.Genres; - -public record GetAvailableGenresQuery : IQuery>; - -public class GetAvailableGenresQueryHandler - : IQueryHandler> +namespace Musify.Application.Genres { - public ValueTask> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken) + public record GetAvailableGenresQuery : IQuery>; + + public class GetAvailableGenresQueryHandler + : IQueryHandler> { - var all = Enum.GetValues(); + public ValueTask> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken) + { + var all = Enum.GetValues(); - IReadOnlyList result = all - .Select(genre => new AvailableGenreResponse( - genre, - all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList())) - .ToList(); + IReadOnlyList result = all + .Select(genre => new AvailableGenreResponse( + genre, + all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList())) + .ToList(); - return ValueTask.FromResult(result); + return ValueTask.FromResult(result); + } } } diff --git a/backend/Core/Musify.Application/Genres/GetGenres.cs b/backend/Core/Musify.Application/Genres/GetGenres.cs index 6ad2465..680793e 100644 --- a/backend/Core/Musify.Application/Genres/GetGenres.cs +++ b/backend/Core/Musify.Application/Genres/GetGenres.cs @@ -4,26 +4,27 @@ using Musify.Application.Contracts; using Musify.Application.Genres.Responses; using Musify.Domain.ValueObjects; -namespace Musify.Application.Genres; - -public record GetGenresQuery : IQuery>; - -public class GetGenresQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Genres { - public async ValueTask> Handle(GetGenresQuery request, CancellationToken cancellationToken) - { - var rows = await database.TrackTags - .AsNoTracking() - .Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active) - .GroupBy(tag => tag.Tag) - .Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() }) - .ToListAsync(cancellationToken); + public record GetGenresQuery : IQuery>; - return rows - .OrderByDescending(row => row.TrackCount) - .ThenBy(row => row.Genre) - .Select(row => new GenreResponse(row.Genre, row.TrackCount)) - .ToList(); + public class GetGenresQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetGenresQuery request, CancellationToken cancellationToken) + { + var rows = await database.TrackTags + .AsNoTracking() + .Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active) + .GroupBy(tag => tag.Tag) + .Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() }) + .ToListAsync(cancellationToken); + + return rows + .OrderByDescending(row => row.TrackCount) + .ThenBy(row => row.Genre) + .Select(row => new GenreResponse(row.Genre, row.TrackCount)) + .ToList(); + } } } diff --git a/backend/Core/Musify.Application/Genres/Responses/AvailableGenreResponse.cs b/backend/Core/Musify.Application/Genres/Responses/AvailableGenreResponse.cs index 2ff905e..85060be 100644 --- a/backend/Core/Musify.Application/Genres/Responses/AvailableGenreResponse.cs +++ b/backend/Core/Musify.Application/Genres/Responses/AvailableGenreResponse.cs @@ -1,5 +1,6 @@ using Musify.Domain.ValueObjects; -namespace Musify.Application.Genres.Responses; - -public record AvailableGenreResponse(Genre Genre, IReadOnlyList IncompatibleWith); +namespace Musify.Application.Genres.Responses +{ + public record AvailableGenreResponse(Genre Genre, IReadOnlyList IncompatibleWith); +} diff --git a/backend/Core/Musify.Application/Genres/Responses/GenreResponse.cs b/backend/Core/Musify.Application/Genres/Responses/GenreResponse.cs index 60ae876..1ffd036 100644 --- a/backend/Core/Musify.Application/Genres/Responses/GenreResponse.cs +++ b/backend/Core/Musify.Application/Genres/Responses/GenreResponse.cs @@ -1,5 +1,6 @@ using Musify.Domain.ValueObjects; -namespace Musify.Application.Genres.Responses; - -public record GenreResponse(Genre Genre, int TrackCount); +namespace Musify.Application.Genres.Responses +{ + public record GenreResponse(Genre Genre, int TrackCount); +} diff --git a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs index 3087e61..5b4a2b1 100644 --- a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs +++ b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs @@ -5,26 +5,27 @@ using Musify.Application.Shared; using Musify.Application.Tracks.Responses; using Musify.Domain.ValueObjects; -namespace Musify.Application.Likes; - -public record GetLikedTracksQuery( - long UserId, - int PageNumber, - int PageSize) - : IQuery>; - -public class GetLikedTracksQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Likes { - public async ValueTask> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken) + public record GetLikedTracksQuery( + long UserId, + int PageNumber, + int PageSize) + : IQuery>; + + public class GetLikedTracksQueryHandler(IDatabase database) + : IQueryHandler> { - return await database.TrackLikes - .AsNoTracking() - .Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active) - .OrderByDescending(like => like.CreatedAt) - .ThenBy(like => like.Id) - .Select(like => like.Track) - .SelectResponse() - .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + public async ValueTask> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken) + { + return await database.TrackLikes + .AsNoTracking() + .Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active) + .OrderByDescending(like => like.CreatedAt) + .ThenBy(like => like.Id) + .Select(like => like.Track) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + } } } diff --git a/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs index 352eb0a..95e5978 100644 --- a/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs +++ b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs @@ -7,52 +7,53 @@ using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Likes; - -public record ToggleTrackLikeCommand(long UserId, Guid TrackId) - : ICommand>; - -public class ToggleTrackLikeCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> +namespace Musify.Application.Likes { - public async ValueTask> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken) + public record ToggleTrackLikeCommand(long UserId, Guid TrackId) + : ICommand>; + + public class ToggleTrackLikeCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> { - var trackExists = await database.Tracks - .AsNoTracking() - .AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); - if (!trackExists) - return AppErrors.NotFound("Track", request.TrackId); - - var existingLike = await database.TrackLikes - .SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken); - - bool nowLiked; - if (existingLike != null) + public async ValueTask> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken) { - database.TrackLikes.Remove(existingLike); - nowLiked = false; - } - else - { - await database.TrackLikes.AddAsync(new TrackLike + var trackExists = await database.Tracks + .AsNoTracking() + .AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); + if (!trackExists) + return AppErrors.NotFound("Track", request.TrackId); + + var existingLike = await database.TrackLikes + .SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken); + + bool nowLiked; + if (existingLike != null) { - UserId = request.UserId, - TrackId = request.TrackId - }, cancellationToken); - nowLiked = true; + database.TrackLikes.Remove(existingLike); + nowLiked = false; + } + else + { + await database.TrackLikes.AddAsync(new TrackLike + { + UserId = request.UserId, + TrackId = request.TrackId + }, cancellationToken); + nowLiked = true; + } + + // A concurrent double click can hit the unique index; the outcome is already in place, so treat it as a no-op. + var saved = await database.TrySaveChangesAsync( + AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken); + if (saved.IsError) + return true; + + logger.LogInformation("User {UserId} {Action} track {TrackId}", + request.UserId, nowLiked ? "liked" : "unliked", request.TrackId); + + return nowLiked; } - - // A concurrent double click can hit the unique index; the outcome is already in place, so treat it as a no-op. - var saved = await database.TrySaveChangesAsync( - AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken); - if (saved.IsError) - return true; - - logger.LogInformation("User {UserId} {Action} track {TrackId}", - request.UserId, nowLiked ? "liked" : "unliked", request.TrackId); - - return nowLiked; } } diff --git a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs index 0bf792f..7cda638 100644 --- a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs +++ b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs @@ -6,124 +6,125 @@ using Musify.Application.Contracts; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Mixes; - -public record GenerateMixesForUserCommand(long UserId) - : ICommand; - -public class GenerateMixesForUserCommandHandler( - IDatabase database, - MixConfiguration mixConfiguration, - ILogger logger) - : ICommandHandler +namespace Musify.Application.Mixes { - private sealed record MixDraft(MixKind Kind, IReadOnlyList TrackIds); + public record GenerateMixesForUserCommand(long UserId) + : ICommand; - public async ValueTask Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken) + public class GenerateMixesForUserCommandHandler( + IDatabase database, + MixConfiguration mixConfiguration, + ILogger logger) + : ICommandHandler { - var userId = request.UserId; + private sealed record MixDraft(MixKind Kind, IReadOnlyList TrackIds); - var libraryTrackIds = await database.Tracks - .AsNoTracking() - .Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active) - .Select(track => track.Id) - .ToListAsync(cancellationToken); - - if (libraryTrackIds.Count == 0) + public async ValueTask Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken) { - logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId); + var userId = request.UserId; + + var libraryTrackIds = await database.Tracks + .AsNoTracking() + .Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active) + .Select(track => track.Id) + .ToListAsync(cancellationToken); + + if (libraryTrackIds.Count == 0) + { + logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId); + return Unit.Value; + } + + var historyTrackIds = await database.ListeningHistories + .AsNoTracking() + .Where(history => history.UserId == userId && history.IsCounted) + .Select(history => history.TrackId) + .Distinct() + .ToListAsync(cancellationToken); + + var historySet = new HashSet(historyTrackIds); + var unheardTrackIds = libraryTrackIds.Where(id => !historySet.Contains(id)).ToList(); + + var drafts = new List(); + + var discovery = Take(Shuffle(unheardTrackIds)); + if (discovery.Count > 0) + { + drafts.Add(new MixDraft(MixKind.Discovery, discovery)); + } + + var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds))); + if (daily.Count > 0) + { + drafts.Add(new MixDraft(MixKind.Daily, daily)); + } + + if (drafts.Count == 0) + { + logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId); + return Unit.Value; + } + + await ReplaceMixesAsync(userId, drafts, cancellationToken); + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId); return Unit.Value; } - var historyTrackIds = await database.ListeningHistories - .AsNoTracking() - .Where(history => history.UserId == userId && history.IsCounted) - .Select(history => history.TrackId) - .Distinct() - .ToListAsync(cancellationToken); + private List Take(IEnumerable trackIds) => + [.. trackIds.Take(mixConfiguration.ItemsPerMix)]; - var historySet = new HashSet(historyTrackIds); - var unheardTrackIds = libraryTrackIds.Where(id => !historySet.Contains(id)).ToList(); + private static List Shuffle(IEnumerable trackIds) => + [.. trackIds.OrderBy(_ => Guid.NewGuid())]; - var drafts = new List(); - - var discovery = Take(Shuffle(unheardTrackIds)); - if (discovery.Count > 0) + private static List Interleave(List first, List second) { - drafts.Add(new MixDraft(MixKind.Discovery, discovery)); - } - - var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds))); - if (daily.Count > 0) - { - drafts.Add(new MixDraft(MixKind.Daily, daily)); - } - - if (drafts.Count == 0) - { - logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId); - return Unit.Value; - } - - await ReplaceMixesAsync(userId, drafts, cancellationToken); - - await database.SaveChangesAsync(cancellationToken); - - logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId); - return Unit.Value; - } - - private List Take(IEnumerable trackIds) => - [.. trackIds.Take(mixConfiguration.ItemsPerMix)]; - - private static List Shuffle(IEnumerable trackIds) => - [.. trackIds.OrderBy(_ => Guid.NewGuid())]; - - private static List Interleave(List first, List second) - { - var merged = new List(first.Count + second.Count); - for (var index = 0; index < Math.Max(first.Count, second.Count); index++) - { - if (index < first.Count) - merged.Add(first[index]); - if (index < second.Count) - merged.Add(second[index]); - } - - return merged; - } - - private async Task ReplaceMixesAsync(long userId, IReadOnlyList drafts, CancellationToken cancellationToken) - { - var previousMixes = await database.Mixes - .Where(mix => mix.UserId == userId) - .ToListAsync(cancellationToken); - - if (previousMixes.Count > 0) - { - database.Mixes.RemoveRange(previousMixes); - } - - for (var position = 0; position < drafts.Count; position++) - { - var draft = drafts[position]; - var mix = new Mix + var merged = new List(first.Count + second.Count); + for (var index = 0; index < Math.Max(first.Count, second.Count); index++) { - UserId = userId, - Kind = draft.Kind, - Position = position - }; + if (index < first.Count) + merged.Add(first[index]); + if (index < second.Count) + merged.Add(second[index]); + } - await database.Mixes.AddAsync(mix, cancellationToken); + return merged; + } - for (var itemPosition = 0; itemPosition < draft.TrackIds.Count; itemPosition++) + private async Task ReplaceMixesAsync(long userId, IReadOnlyList drafts, CancellationToken cancellationToken) + { + var previousMixes = await database.Mixes + .Where(mix => mix.UserId == userId) + .ToListAsync(cancellationToken); + + if (previousMixes.Count > 0) { - await database.MixItems.AddAsync(new MixItem + database.Mixes.RemoveRange(previousMixes); + } + + for (var position = 0; position < drafts.Count; position++) + { + var draft = drafts[position]; + var mix = new Mix { - MixId = mix.Id, - Position = itemPosition, - TrackId = draft.TrackIds[itemPosition] - }, cancellationToken); + UserId = userId, + Kind = draft.Kind, + Position = position + }; + + await database.Mixes.AddAsync(mix, cancellationToken); + + for (var itemPosition = 0; itemPosition < draft.TrackIds.Count; itemPosition++) + { + await database.MixItems.AddAsync(new MixItem + { + MixId = mix.Id, + Position = itemPosition, + TrackId = draft.TrackIds[itemPosition] + }, cancellationToken); + } } } } diff --git a/backend/Core/Musify.Application/Mixes/GetMixById.cs b/backend/Core/Musify.Application/Mixes/GetMixById.cs index e9e44f1..65c8a9d 100644 --- a/backend/Core/Musify.Application/Mixes/GetMixById.cs +++ b/backend/Core/Musify.Application/Mixes/GetMixById.cs @@ -5,27 +5,28 @@ using Musify.Application.Contracts; using Musify.Application.Mixes.Responses; using Musify.Application.Shared; -namespace Musify.Application.Mixes; - -public record GetMixByIdQuery(long UserId, Guid MixId) - : IQuery>; - -public class GetMixByIdQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Mixes { - public async ValueTask> Handle(GetMixByIdQuery request, CancellationToken cancellationToken) + public record GetMixByIdQuery(long UserId, Guid MixId) + : IQuery>; + + public class GetMixByIdQueryHandler(IDatabase database) + : IQueryHandler> { - var mix = await database.Mixes - .AsNoTracking() - .Include(mix => mix.Items) - .SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken); + public async ValueTask> Handle(GetMixByIdQuery request, CancellationToken cancellationToken) + { + var mix = await database.Mixes + .AsNoTracking() + .Include(mix => mix.Items) + .SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken); - if (mix == null) - return AppErrors.NotFound("Mix", request.MixId); + if (mix == null) + return AppErrors.NotFound("Mix", request.MixId); - var orderedItems = mix.Items.OrderBy(item => item.Position).ToList(); - var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken); + var orderedItems = mix.Items.OrderBy(item => item.Position).ToList(); + var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken); - return new MixApplicationResponse(mix.Id, mix.Kind, items.Count, items); + return new MixApplicationResponse(mix.Id, mix.Kind, items.Count, items); + } } } diff --git a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs index 303c41c..73ea1bb 100644 --- a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs +++ b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs @@ -3,48 +3,49 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Mixes.Responses; -namespace Musify.Application.Mixes; - -public record GetMixesByUserIdQuery(long UserId) - : IQuery>; - -public class GetMixesByUserIdQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Mixes { - public async ValueTask> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken) + public record GetMixesByUserIdQuery(long UserId) + : IQuery>; + + public class GetMixesByUserIdQueryHandler(IDatabase database) + : IQueryHandler> { - var mixes = await database.Mixes - .AsNoTracking() - .Where(mix => mix.UserId == request.UserId) - .Include(mix => mix.Items) - .OrderBy(mix => mix.Position) - .ThenBy(mix => mix.Id) - .ToListAsync(cancellationToken); - - var orderedItemsByMix = mixes.ToDictionary( - mix => mix.Id, - mix => mix.Items.OrderBy(item => item.Position).ToList()); - - var coverItems = orderedItemsByMix.Values - .SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount)) - .ToList(); - var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken)) - .GroupBy(response => response.TrackId) - .ToDictionary(group => group.Key, group => group.First()); - - var responses = new List(mixes.Count); - foreach (var mix in mixes) + public async ValueTask> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken) { - var orderedItems = orderedItemsByMix[mix.Id]; - var covers = orderedItems - .Take(MixApplicationResponse.CoverItemCount) - .Where(item => coverResponses.ContainsKey(item.TrackId)) - .Select(item => coverResponses[item.TrackId]) + var mixes = await database.Mixes + .AsNoTracking() + .Where(mix => mix.UserId == request.UserId) + .Include(mix => mix.Items) + .OrderBy(mix => mix.Position) + .ThenBy(mix => mix.Id) + .ToListAsync(cancellationToken); + + var orderedItemsByMix = mixes.ToDictionary( + mix => mix.Id, + mix => mix.Items.OrderBy(item => item.Position).ToList()); + + var coverItems = orderedItemsByMix.Values + .SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount)) .ToList(); + var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken)) + .GroupBy(response => response.TrackId) + .ToDictionary(group => group.Key, group => group.First()); - responses.Add(new MixApplicationResponse(mix.Id, mix.Kind, orderedItems.Count, covers)); + var responses = new List(mixes.Count); + foreach (var mix in mixes) + { + var orderedItems = orderedItemsByMix[mix.Id]; + var covers = orderedItems + .Take(MixApplicationResponse.CoverItemCount) + .Where(item => coverResponses.ContainsKey(item.TrackId)) + .Select(item => coverResponses[item.TrackId]) + .ToList(); + + responses.Add(new MixApplicationResponse(mix.Id, mix.Kind, orderedItems.Count, covers)); + } + + return responses; } - - return responses; } } diff --git a/backend/Core/Musify.Application/Mixes/MixItemMapper.cs b/backend/Core/Musify.Application/Mixes/MixItemMapper.cs index e4928dc..e5a6520 100644 --- a/backend/Core/Musify.Application/Mixes/MixItemMapper.cs +++ b/backend/Core/Musify.Application/Mixes/MixItemMapper.cs @@ -4,37 +4,38 @@ using Musify.Application.Mixes.Responses; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Mixes; - - -internal static class MixItemMapper +namespace Musify.Application.Mixes { - public static async Task> ToResponsesAsync( - IDatabase database, - IReadOnlyList orderedItems, - CancellationToken cancellationToken) + + internal static class MixItemMapper { - var trackIds = orderedItems.Select(item => item.TrackId).Distinct().ToList(); + public static async Task> ToResponsesAsync( + IDatabase database, + IReadOnlyList orderedItems, + CancellationToken cancellationToken) + { + var trackIds = orderedItems.Select(item => item.TrackId).Distinct().ToList(); - var tracksById = await database.Tracks - .AsNoTracking() - .Include(track => track.Owner) - .Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) - .Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) }) - .ToDictionaryAsync(x => x.Track.Id, cancellationToken); + var tracksById = await database.Tracks + .AsNoTracking() + .Include(track => track.Owner) + .Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) + .Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) }) + .ToDictionaryAsync(x => x.Track.Id, cancellationToken); - return orderedItems - .Where(item => tracksById.ContainsKey(item.TrackId)) - .Select(item => - { - var entry = tracksById[item.TrackId]; - return new MixItemApplicationResponse( - item.TrackId, - entry.Track.Title, - entry.Track.Owner.Name, - entry.Track.DurationSeconds, - entry.ListensCount); - }) - .ToList(); + return orderedItems + .Where(item => tracksById.ContainsKey(item.TrackId)) + .Select(item => + { + var entry = tracksById[item.TrackId]; + return new MixItemApplicationResponse( + item.TrackId, + entry.Track.Title, + entry.Track.Owner.Name, + entry.Track.DurationSeconds, + entry.ListensCount); + }) + .ToList(); + } } } diff --git a/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs b/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs index 801988e..6172bbc 100644 --- a/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs +++ b/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs @@ -1,12 +1,13 @@ using Musify.Domain.ValueObjects; -namespace Musify.Application.Mixes.Responses; - -public record MixApplicationResponse( - Guid Id, - MixKind Kind, - int ItemCount, - IReadOnlyList Items) +namespace Musify.Application.Mixes.Responses { - public const int CoverItemCount = 4; + public record MixApplicationResponse( + Guid Id, + MixKind Kind, + int ItemCount, + IReadOnlyList Items) + { + public const int CoverItemCount = 4; + } } diff --git a/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs b/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs index 12d2192..1b75458 100644 --- a/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs +++ b/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs @@ -1,8 +1,9 @@ -namespace Musify.Application.Mixes.Responses; - -public record MixItemApplicationResponse( - Guid TrackId, - string Title, - string? Artist, - double DurationSeconds, - int ListensCount); +namespace Musify.Application.Mixes.Responses +{ + public record MixItemApplicationResponse( + Guid TrackId, + string Title, + string? Artist, + double DurationSeconds, + int ListensCount); +} diff --git a/backend/Core/Musify.Application/Pictures/GetCover.cs b/backend/Core/Musify.Application/Pictures/GetCover.cs index 6e91e2d..1e45cbc 100644 --- a/backend/Core/Musify.Application/Pictures/GetCover.cs +++ b/backend/Core/Musify.Application/Pictures/GetCover.cs @@ -7,69 +7,70 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Domain.ValueObjects; -namespace Musify.Application.Pictures; - -public enum CoverOwner +namespace Musify.Application.Pictures { - Track, - Album, - PlayList -} - -public record GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size = PictureSize.Medium) - : IQuery>; - -public record CoverLocation(string Bucket, string Key, string ContentType); - -public class GetCoverQueryHandler( - IDatabase database, - ApplicationStorageConfiguration storageConfiguration, - TrackConfiguration trackConfiguration, - AlbumConfiguration albumConfiguration, - PlayListConfiguration playListConfiguration) - : IQueryHandler> -{ - private sealed record CoverSource(long OwnerUserId, string? Original, string? Small, string? Medium, string? Large); - - public async ValueTask> Handle(GetCoverQuery request, CancellationToken cancellationToken) + public enum CoverOwner { - var (source, routes) = request.Owner switch + Track, + Album, + PlayList + } + + public record GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size = PictureSize.Medium) + : IQuery>; + + public record CoverLocation(string Bucket, string Key, string ContentType); + + public class GetCoverQueryHandler( + IDatabase database, + ApplicationStorageConfiguration storageConfiguration, + TrackConfiguration trackConfiguration, + AlbumConfiguration albumConfiguration, + PlayListConfiguration playListConfiguration) + : IQueryHandler> + { + private sealed record CoverSource(long OwnerUserId, string? Original, string? Small, string? Medium, string? Large); + + public async ValueTask> Handle(GetCoverQuery request, CancellationToken cancellationToken) { - CoverOwner.Track => (await database.Tracks.AsNoTracking() - .Where(t => t.Id == request.Id && t.LifeCycleStatus == LifeCycleStatus.Active) - .Select(t => new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName)) - .SingleOrDefaultAsync(cancellationToken), trackConfiguration.Routes), - CoverOwner.Album => (await database.Albums.AsNoTracking() - .Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null) - .Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName)) - .SingleOrDefaultAsync(cancellationToken), albumConfiguration.Routes), - _ => (await database.PlayLists.AsNoTracking() - .Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active && p.Pictures != null) - .Select(p => new CoverSource(p.OwnerUserId, p.Pictures!.OriginalName, p.Pictures.SmallName, p.Pictures.MediumName, p.Pictures.LargeName)) - .SingleOrDefaultAsync(cancellationToken), playListConfiguration.Routes) - }; + var (source, routes) = request.Owner switch + { + CoverOwner.Track => (await database.Tracks.AsNoTracking() + .Where(t => t.Id == request.Id && t.LifeCycleStatus == LifeCycleStatus.Active) + .Select(t => new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName)) + .SingleOrDefaultAsync(cancellationToken), trackConfiguration.Routes), + CoverOwner.Album => (await database.Albums.AsNoTracking() + .Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null) + .Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName)) + .SingleOrDefaultAsync(cancellationToken), albumConfiguration.Routes), + _ => (await database.PlayLists.AsNoTracking() + .Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active && p.Pictures != null) + .Select(p => new CoverSource(p.OwnerUserId, p.Pictures!.OriginalName, p.Pictures.SmallName, p.Pictures.MediumName, p.Pictures.LargeName)) + .SingleOrDefaultAsync(cancellationToken), playListConfiguration.Routes) + }; - var entity = request.Owner.ToString(); - if (source == null) - return AppErrors.NotFound($"{entity}Cover", request.Id); + var entity = request.Owner.ToString(); + if (source == null) + return AppErrors.NotFound($"{entity}Cover", request.Id); - var resizedName = request.Size switch - { - PictureSize.Small => source.Small, - PictureSize.Large => source.Large, - _ => source.Medium - }; + var resizedName = request.Size switch + { + PictureSize.Small => source.Small, + PictureSize.Large => source.Large, + _ => source.Medium + }; - // The resized files only exist once processing has finished; until then serve the original. - var (name, key) = !string.IsNullOrEmpty(resizedName) - ? (resizedName, routes.BuildPicturePath(request.Size, resizedName)) - : !string.IsNullOrEmpty(source.Original) - ? (source.Original, routes.BuildOriginalPicturePath(source.OwnerUserId, source.Original)) - : (null, null); + // The resized files only exist once processing has finished; until then serve the original. + var (name, key) = !string.IsNullOrEmpty(resizedName) + ? (resizedName, routes.BuildPicturePath(request.Size, resizedName)) + : !string.IsNullOrEmpty(source.Original) + ? (source.Original, routes.BuildOriginalPicturePath(source.OwnerUserId, source.Original)) + : (null, null); - if (name == null || key == null) - return AppErrors.NotFound($"{entity}Cover", request.Id); + if (name == null || key == null) + return AppErrors.NotFound($"{entity}Cover", request.Id); - return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name)); + return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name)); + } } } diff --git a/backend/Core/Musify.Application/Pictures/PictureSourceChange.cs b/backend/Core/Musify.Application/Pictures/PictureSourceChange.cs index ea8070b..90df675 100644 --- a/backend/Core/Musify.Application/Pictures/PictureSourceChange.cs +++ b/backend/Core/Musify.Application/Pictures/PictureSourceChange.cs @@ -5,31 +5,32 @@ using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Pictures; - -/// What an album or playlist needs to switch to a freshly uploaded picture. -public sealed record PreparedPicture(UploadIntent Intent, EntityPictures Pictures, string FinalKey, ImageSizes Sizes); - -public static class PictureSourceChange +namespace Musify.Application.Pictures { - public static async Task> PrepareAsync( - UploadIntentValidator uploadIntentValidator, - Guid intentId, - long userId, - UploadIntentPurpose purpose, - IPictureOwnerConfiguration owner, - CancellationToken cancellationToken) + /// What an album or playlist needs to switch to a freshly uploaded picture. + public sealed record PreparedPicture(UploadIntent Intent, EntityPictures Pictures, string FinalKey, ImageSizes Sizes); + + public static class PictureSourceChange { - var validation = await uploadIntentValidator.ValidateAndLoadAsync(intentId, userId, purpose, cancellationToken); - if (validation.IsError) - return validation.Errors; + public static async Task> PrepareAsync( + UploadIntentValidator uploadIntentValidator, + Guid intentId, + long userId, + UploadIntentPurpose purpose, + IPictureOwnerConfiguration owner, + CancellationToken cancellationToken) + { + var validation = await uploadIntentValidator.ValidateAndLoadAsync(intentId, userId, purpose, cancellationToken); + if (validation.IsError) + return validation.Errors; - var intent = validation.Value; + var intent = validation.Value; - return new PreparedPicture( - intent, - EntityPictures.Pending(intent.ObjectName), - owner.Routes.BuildOriginalPicturePath(userId, intent.ObjectName), - owner.PicturesSizes.ToImageSizes(owner.Routes)); + return new PreparedPicture( + intent, + EntityPictures.Pending(intent.ObjectName), + owner.Routes.BuildOriginalPicturePath(userId, intent.ObjectName), + owner.PicturesSizes.ToImageSizes(owner.Routes)); + } } } diff --git a/backend/Core/Musify.Application/Pictures/Responses/PictureUploadResponse.cs b/backend/Core/Musify.Application/Pictures/Responses/PictureUploadResponse.cs index 60cafb6..dc67b39 100644 --- a/backend/Core/Musify.Application/Pictures/Responses/PictureUploadResponse.cs +++ b/backend/Core/Musify.Application/Pictures/Responses/PictureUploadResponse.cs @@ -1,10 +1,11 @@ -namespace Musify.Application.Pictures.Responses; - -public record PictureUploadResponse( - Guid IntentId, - string Bucket, - string Key, - string PictureName, - string ContentType, - int ExpiresInSeconds, - string UploadUrl); +namespace Musify.Application.Pictures.Responses +{ + public record PictureUploadResponse( + Guid IntentId, + string Bucket, + string Key, + string PictureName, + string ContentType, + int ExpiresInSeconds, + string UploadUrl); +} diff --git a/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs b/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs index 12d5bb4..87db300 100644 --- a/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/AddTracksToPlayList.cs @@ -7,67 +7,68 @@ using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.PlayLists; - -/// Appends tracks to a playlist in the given order, skipping the ones it already has. -public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList TrackIds) - : ICommand>; - -public class AddTracksToPlayListCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> +namespace Musify.Application.PlayLists { - public async ValueTask> Handle(AddTracksToPlayListCommand request, CancellationToken cancellationToken) + /// Appends tracks to a playlist in the given order, skipping the ones it already has. + public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList TrackIds) + : ICommand>; + + public class AddTracksToPlayListCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> { - var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); - if (playList.IsError) - return playList.Errors; - - var requestedIds = request.TrackIds.Distinct().ToList(); - - var existingIds = await database.Tracks - .AsNoTracking() - .Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) - .Select(track => track.Id) - .ToListAsync(cancellationToken); - - var missing = requestedIds.FirstOrDefault(id => !existingIds.Contains(id)); - if (missing != Guid.Empty) - return AppErrors.NotFound("Track", missing); - - var alreadyInPlayList = await database.PlayListHasTracks - .AsNoTracking() - .Where(link => link.PlayListId == request.PlayListId && requestedIds.Contains(link.TrackId)) - .Select(link => link.TrackId) - .ToListAsync(cancellationToken); - - var toAdd = requestedIds.Where(id => !alreadyInPlayList.Contains(id)).ToList(); - if (toAdd.Count == 0) - return Result.Success; - - // Playlist positions are internal indexes, so they start at 0. - var nextPosition = (await database.PlayListHasTracks - .Where(link => link.PlayListId == request.PlayListId) - .MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1; - - foreach (var trackId in toAdd) + public async ValueTask> Handle(AddTracksToPlayListCommand request, CancellationToken cancellationToken) { - await database.PlayListHasTracks.AddAsync(new PlayListHasTrack + var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); + if (playList.IsError) + return playList.Errors; + + var requestedIds = request.TrackIds.Distinct().ToList(); + + var existingIds = await database.Tracks + .AsNoTracking() + .Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active) + .Select(track => track.Id) + .ToListAsync(cancellationToken); + + var missing = requestedIds.FirstOrDefault(id => !existingIds.Contains(id)); + if (missing != Guid.Empty) + return AppErrors.NotFound("Track", missing); + + var alreadyInPlayList = await database.PlayListHasTracks + .AsNoTracking() + .Where(link => link.PlayListId == request.PlayListId && requestedIds.Contains(link.TrackId)) + .Select(link => link.TrackId) + .ToListAsync(cancellationToken); + + var toAdd = requestedIds.Where(id => !alreadyInPlayList.Contains(id)).ToList(); + if (toAdd.Count == 0) + return Result.Success; + + // Playlist positions are internal indexes, so they start at 0. + var nextPosition = (await database.PlayListHasTracks + .Where(link => link.PlayListId == request.PlayListId) + .MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1; + + foreach (var trackId in toAdd) { - PlayListId = request.PlayListId, - TrackId = trackId, - Position = nextPosition++ - }, cancellationToken); + await database.PlayListHasTracks.AddAsync(new PlayListHasTrack + { + PlayListId = request.PlayListId, + TrackId = trackId, + Position = nextPosition++ + }, cancellationToken); + } + + var saved = await database.TrySaveChangesAsync( + AppErrors.Conflict("PlayList.ConcurrentChange", "The playlist changed while adding tracks. Try again."), cancellationToken); + if (saved.IsError) + return saved; + + logger.LogInformation("Added {Count} tracks to playlist {PlayListId}", toAdd.Count, request.PlayListId); + + return Result.Success; } - - var saved = await database.TrySaveChangesAsync( - AppErrors.Conflict("PlayList.ConcurrentChange", "The playlist changed while adding tracks. Try again."), cancellationToken); - if (saved.IsError) - return saved; - - logger.LogInformation("Added {Count} tracks to playlist {PlayListId}", toAdd.Count, request.PlayListId); - - return Result.Success; } } diff --git a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs index 2b37a88..fec544d 100644 --- a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs @@ -12,67 +12,68 @@ using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.PlayLists; - -public record CreatePlayListCommand( - long UserId, - string Name, - string? Description, - Guid? PictureIntentId, - PlayListVisibility Visibility = PlayListVisibility.Private) - : ICommand>; - -public class CreatePlayListCommandHandler( - IEventBus eventBus, - IDatabase database, - UploadIntentValidator uploadIntentValidator, - ILogger logger, - PlayListConfiguration playListConfiguration) - : ICommandHandler> +namespace Musify.Application.PlayLists { - public async ValueTask> Handle(CreatePlayListCommand request, CancellationToken cancellationToken) + public record CreatePlayListCommand( + long UserId, + string Name, + string? Description, + Guid? PictureIntentId, + PlayListVisibility Visibility = PlayListVisibility.Private) + : ICommand>; + + public class CreatePlayListCommandHandler( + IEventBus eventBus, + IDatabase database, + UploadIntentValidator uploadIntentValidator, + ILogger logger, + PlayListConfiguration playListConfiguration) + : ICommandHandler> { - var userExists = await database.Users - .AsNoTracking() - .AnyAsync(u => u.Id == request.UserId, cancellationToken); - if (!userExists) - return AppErrors.NotFound("User", request.UserId); - - PreparedPicture? picture = null; - if (request.PictureIntentId is { } intentId) + public async ValueTask> Handle(CreatePlayListCommand request, CancellationToken cancellationToken) { - var prepared = await PictureSourceChange.PrepareAsync( - uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken); - if (prepared.IsError) - return prepared.Errors; + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(u => u.Id == request.UserId, cancellationToken); + if (!userExists) + return AppErrors.NotFound("User", request.UserId); - picture = prepared.Value; + PreparedPicture? picture = null; + if (request.PictureIntentId is { } intentId) + { + var prepared = await PictureSourceChange.PrepareAsync( + uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken); + if (prepared.IsError) + return prepared.Errors; + + picture = prepared.Value; + } + + var name = request.Name.Trim(); + var playList = new PlayList + { + OwnerUserId = request.UserId, + Name = name, + NormalizedName = TextNormalizer.Normalize(name), + Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), + Pictures = picture?.Pictures, + Visibility = request.Visibility + }; + await database.PlayLists.AddAsync(playList, cancellationToken); + + if (picture != null) + { + await eventBus.PublishAsync( + new CreatePlayListResourcesEvent( + playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), + cancellationToken); + } + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId); + + return PlayListApplicationResponse.FromEntity(playList); } - - var name = request.Name.Trim(); - var playList = new PlayList - { - OwnerUserId = request.UserId, - Name = name, - NormalizedName = TextNormalizer.Normalize(name), - Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), - Pictures = picture?.Pictures, - Visibility = request.Visibility - }; - await database.PlayLists.AddAsync(playList, cancellationToken); - - if (picture != null) - { - await eventBus.PublishAsync( - new CreatePlayListResourcesEvent( - playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), - cancellationToken); - } - - await database.SaveChangesAsync(cancellationToken); - - logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId); - - return PlayListApplicationResponse.FromEntity(playList); } } diff --git a/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs b/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs index 6dad744..fa465ec 100644 --- a/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs @@ -6,31 +6,32 @@ using Musify.Application.Events; using Musify.Application.Shared; using Musify.Domain.ValueObjects; -namespace Musify.Application.PlayLists; - -public record DeletePlayListCommand(long UserId, Guid PlayListId) - : ICommand>; - -public class DeletePlayListCommandHandler( - IDatabase database, - IEventBus eventBus, - ILogger logger) - : ICommandHandler> +namespace Musify.Application.PlayLists { - public async ValueTask> Handle(DeletePlayListCommand request, CancellationToken cancellationToken) + public record DeletePlayListCommand(long UserId, Guid PlayListId) + : ICommand>; + + public class DeletePlayListCommandHandler( + IDatabase database, + IEventBus eventBus, + ILogger logger) + : ICommandHandler> { - var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); - if (found.IsError) - return found.Errors; + public async ValueTask> Handle(DeletePlayListCommand request, CancellationToken cancellationToken) + { + var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; - var playList = found.Value; - playList.LifeCycleStatus = LifeCycleStatus.Removing; + var playList = found.Value; + playList.LifeCycleStatus = LifeCycleStatus.Removing; - await eventBus.PublishAsync(new DeletePlayListEvent(playList.Id, request.UserId), cancellationToken); - await database.SaveChangesAsync(cancellationToken); + await eventBus.PublishAsync(new DeletePlayListEvent(playList.Id, request.UserId), cancellationToken); + await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Marked playlist {PlayListId} as removing", request.PlayListId); + logger.LogInformation("Marked playlist {PlayListId} as removing", request.PlayListId); - return Result.Success; + return Result.Success; + } } } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs index 8cbfdae..d532bc2 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs @@ -5,26 +5,27 @@ using Musify.Application.Contracts; using Musify.Application.PlayLists.Responses; using Musify.Application.Shared; -namespace Musify.Application.PlayLists; - -public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null) - : IQuery>; - -public class GetPlayListByIdQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.PlayLists { - public async ValueTask> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken) - { - var playList = await database.PlayLists - .AsNoTracking() - .Active() - .VisibleTo(request.RequestingUserId) - .Where(p => p.Id == request.Id) - .SelectResponse() - .SingleOrDefaultAsync(cancellationToken); + public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null) + : IQuery>; - return playList == null - ? AppErrors.NotFound("PlayList", request.Id) - : playList; + public class GetPlayListByIdQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken) + { + var playList = await database.PlayLists + .AsNoTracking() + .Active() + .VisibleTo(request.RequestingUserId) + .Where(p => p.Id == request.Id) + .SelectResponse() + .SingleOrDefaultAsync(cancellationToken); + + return playList == null + ? AppErrors.NotFound("PlayList", request.Id) + : playList; + } } } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs index 6aa90db..9da4317 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs @@ -7,35 +7,36 @@ using Musify.Application.Shared; using Musify.Application.Tracks.Responses; using Musify.Domain.ValueObjects; -namespace Musify.Application.PlayLists; - -public record GetPlayListTracksQuery( - Guid PlayListId, - int PageNumber, - int PageSize, - long? ViewerId = null) - : IQuery>>; - -public class GetPlayListTracksQueryHandler(IDatabase database) - : IQueryHandler>> +namespace Musify.Application.PlayLists { - public async ValueTask>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken) - { - var playListVisible = await database.PlayLists - .AsNoTracking() - .Active() - .VisibleTo(request.ViewerId) - .AnyAsync(playList => playList.Id == request.PlayListId, cancellationToken); - if (!playListVisible) - return AppErrors.NotFound("PlayList", request.PlayListId); + public record GetPlayListTracksQuery( + Guid PlayListId, + int PageNumber, + int PageSize, + long? ViewerId = null) + : IQuery>>; - return await database.PlayListHasTracks - .AsNoTracking() - .Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) - .OrderBy(playListTrack => playListTrack.Position) - .ThenBy(playListTrack => playListTrack.Id) - .Select(playListTrack => playListTrack.Track) - .SelectResponse() - .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + public class GetPlayListTracksQueryHandler(IDatabase database) + : IQueryHandler>> + { + public async ValueTask>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken) + { + var playListVisible = await database.PlayLists + .AsNoTracking() + .Active() + .VisibleTo(request.ViewerId) + .AnyAsync(playList => playList.Id == request.PlayListId, cancellationToken); + if (!playListVisible) + return AppErrors.NotFound("PlayList", request.PlayListId); + + return await database.PlayListHasTracks + .AsNoTracking() + .Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) + .OrderBy(playListTrack => playListTrack.Position) + .ThenBy(playListTrack => playListTrack.Id) + .Select(playListTrack => playListTrack.Track) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + } } } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs index 6105348..14f49ee 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs @@ -4,37 +4,38 @@ using Musify.Application.Contracts; using Musify.Application.PlayLists.Responses; using Musify.Application.Shared; -namespace Musify.Application.PlayLists; - -public record GetPlayListsByUserIdQuery( - long UserId, - string? Name, - int PageNumber, - int PageSize, - long? ViewerId = null) - : IQuery>; - -public class GetPlayListsByUserIdQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.PlayLists { - public async ValueTask> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken) + public record GetPlayListsByUserIdQuery( + long UserId, + string? Name, + int PageNumber, + int PageSize, + long? ViewerId = null) + : IQuery>; + + public class GetPlayListsByUserIdQueryHandler(IDatabase database) + : IQueryHandler> { - var playListsQuery = database.PlayLists - .AsNoTracking() - .Active() - .VisibleTo(request.ViewerId) - .Where(playList => playList.OwnerUserId == request.UserId); - - if (!string.IsNullOrWhiteSpace(request.Name)) + public async ValueTask> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken) { - var normalizedName = TextNormalizer.Normalize(request.Name); - playListsQuery = playListsQuery.Where(playList => playList.NormalizedName.Contains(normalizedName)); - } + var playListsQuery = database.PlayLists + .AsNoTracking() + .Active() + .VisibleTo(request.ViewerId) + .Where(playList => playList.OwnerUserId == request.UserId); - return await playListsQuery - .OrderBy(playList => playList.CreatedAt) - .ThenBy(playList => playList.Id) - .SelectResponse() - .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + if (!string.IsNullOrWhiteSpace(request.Name)) + { + var normalizedName = TextNormalizer.Normalize(request.Name); + playListsQuery = playListsQuery.Where(playList => playList.NormalizedName.Contains(normalizedName)); + } + + return await playListsQuery + .OrderBy(playList => playList.CreatedAt) + .ThenBy(playList => playList.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + } } } diff --git a/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs b/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs index e6c44c2..1e071db 100644 --- a/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs @@ -5,41 +5,42 @@ using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Application.Shared; -namespace Musify.Application.PlayLists; - -public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId) - : ICommand>; - -public class RemoveTrackFromPlayListCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> +namespace Musify.Application.PlayLists { - public async ValueTask> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken) + public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId) + : ICommand>; + + public class RemoveTrackFromPlayListCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> { - var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); - if (playList.IsError) - return playList.Errors; + public async ValueTask> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken) + { + var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); + if (playList.IsError) + return playList.Errors; - var playListTracks = await database.PlayListHasTracks - .Where(plt => plt.PlayListId == request.PlayListId) - .OrderBy(plt => plt.Position) - .ToListAsync(cancellationToken); + var playListTracks = await database.PlayListHasTracks + .Where(plt => plt.PlayListId == request.PlayListId) + .OrderBy(plt => plt.Position) + .ToListAsync(cancellationToken); - var link = playListTracks.SingleOrDefault(plt => plt.TrackId == request.TrackId); - if (link == null) - return Error.NotFound("PlayList.TrackNotFound", "Track is not in the playlist."); + var link = playListTracks.SingleOrDefault(plt => plt.TrackId == request.TrackId); + if (link == null) + return Error.NotFound("PlayList.TrackNotFound", "Track is not in the playlist."); - database.PlayListHasTracks.Remove(link); + database.PlayListHasTracks.Remove(link); - var position = 0; - foreach (var remaining in playListTracks.Where(plt => plt.Id != link.Id)) - remaining.Position = position++; + var position = 0; + foreach (var remaining in playListTracks.Where(plt => plt.Id != link.Id)) + remaining.Position = position++; - await database.SaveChangesAsync(cancellationToken); + await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId); + logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId); - return Result.Success; + return Result.Success; + } } } diff --git a/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs b/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs index 44d9e68..0ebfd95 100644 --- a/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs +++ b/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs @@ -5,20 +5,21 @@ using Musify.Application.Pictures.Responses; using Musify.Application.Services; using Musify.Domain.ValueObjects; -namespace Musify.Application.PlayLists; - -public record RequestPlayListPictureUploadCommand( - long UserId, - string FileType, - string ContentType, - long? ExpectedSizeBytes = null) - : ICommand>; - -public class RequestPlayListPictureUploadCommandHandler(UploadIntentIssuer issuer, PlayListConfiguration playListConfiguration) - : ICommandHandler> +namespace Musify.Application.PlayLists { - public async ValueTask> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) => - await issuer.IssuePictureAsync( - request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, - request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken); + public record RequestPlayListPictureUploadCommand( + long UserId, + string FileType, + string ContentType, + long? ExpectedSizeBytes = null) + : ICommand>; + + public class RequestPlayListPictureUploadCommandHandler(UploadIntentIssuer issuer, PlayListConfiguration playListConfiguration) + : ICommandHandler> + { + public async ValueTask> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) => + await issuer.IssuePictureAsync( + request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, + request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken); + } } diff --git a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs index 48ea745..10e4021 100644 --- a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs +++ b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs @@ -2,44 +2,45 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.PlayLists.Responses; - -public record PlayListApplicationResponse( - Guid Id, - string Name, - string? Description, - string? SmallImageKeyName, - string? MediumImageKeyName, - string? LargeImageKeyName, - PlayListVisibility Visibility, - DateTime CreatedAt, - DateTime UpdatedAt, - IReadOnlyList CoverTrackIds, - long OwnerUserId, - int TrackCount, - double DurationSeconds) +namespace Musify.Application.PlayLists.Responses { - public const int CoverTrackCount = PlayListProjections.CoverTrackCount; - - public static PlayListApplicationResponse FromEntity( - PlayList playList, - IReadOnlyList? coverTrackIds = null, - int trackCount = 0, - double durationSeconds = 0) + public record PlayListApplicationResponse( + Guid Id, + string Name, + string? Description, + string? SmallImageKeyName, + string? MediumImageKeyName, + string? LargeImageKeyName, + PlayListVisibility Visibility, + DateTime CreatedAt, + DateTime UpdatedAt, + IReadOnlyList CoverTrackIds, + long OwnerUserId, + int TrackCount, + double DurationSeconds) { - return new PlayListApplicationResponse( - playList.Id, - playList.Name, - playList.Description, - playList.Pictures?.SmallName, - playList.Pictures?.MediumName, - playList.Pictures?.LargeName, - playList.Visibility, - playList.CreatedAt, - playList.UpdatedAt, - coverTrackIds ?? [], - playList.OwnerUserId, - trackCount, - durationSeconds); + public const int CoverTrackCount = PlayListProjections.CoverTrackCount; + + public static PlayListApplicationResponse FromEntity( + PlayList playList, + IReadOnlyList? coverTrackIds = null, + int trackCount = 0, + double durationSeconds = 0) + { + return new PlayListApplicationResponse( + playList.Id, + playList.Name, + playList.Description, + playList.Pictures?.SmallName, + playList.Pictures?.MediumName, + playList.Pictures?.LargeName, + playList.Visibility, + playList.CreatedAt, + playList.UpdatedAt, + coverTrackIds ?? [], + playList.OwnerUserId, + trackCount, + durationSeconds); + } } } diff --git a/backend/Core/Musify.Application/PlayLists/Responses/PlayListProjections.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListProjections.cs index 9e23757..5f99617 100644 --- a/backend/Core/Musify.Application/PlayLists/Responses/PlayListProjections.cs +++ b/backend/Core/Musify.Application/PlayLists/Responses/PlayListProjections.cs @@ -1,37 +1,38 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.PlayLists.Responses; - -public static class PlayListProjections +namespace Musify.Application.PlayLists.Responses { - public const int CoverTrackCount = 4; + public static class PlayListProjections + { + public const int CoverTrackCount = 4; - public static IQueryable Active(this IQueryable query) => - query.Where(playList => playList.LifeCycleStatus == LifeCycleStatus.Active); + public static IQueryable Active(this IQueryable query) => + query.Where(playList => playList.LifeCycleStatus == LifeCycleStatus.Active); - public static IQueryable VisibleTo(this IQueryable query, long? viewerId) => - query.Where(playList => playList.Visibility == PlayListVisibility.Public || playList.OwnerUserId == viewerId); + public static IQueryable VisibleTo(this IQueryable query, long? viewerId) => + query.Where(playList => playList.Visibility == PlayListVisibility.Public || playList.OwnerUserId == viewerId); - public static IQueryable SelectResponse(this IQueryable query) => - query.Select(playList => new PlayListApplicationResponse( - playList.Id, - playList.Name, - playList.Description, - playList.Pictures!.SmallName, - playList.Pictures!.MediumName, - playList.Pictures!.LargeName, - playList.Visibility, - playList.CreatedAt, - playList.UpdatedAt, - playList.PlayListTracks - .OrderBy(playListTrack => playListTrack.Position) - .Take(CoverTrackCount) - .Select(playListTrack => playListTrack.TrackId) - .ToList(), - playList.OwnerUserId, - playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active), - playList.PlayListTracks - .Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) - .Sum(playListTrack => playListTrack.Track.DurationSeconds))); + public static IQueryable SelectResponse(this IQueryable query) => + query.Select(playList => new PlayListApplicationResponse( + playList.Id, + playList.Name, + playList.Description, + playList.Pictures!.SmallName, + playList.Pictures!.MediumName, + playList.Pictures!.LargeName, + playList.Visibility, + playList.CreatedAt, + playList.UpdatedAt, + playList.PlayListTracks + .OrderBy(playListTrack => playListTrack.Position) + .Take(CoverTrackCount) + .Select(playListTrack => playListTrack.TrackId) + .ToList(), + playList.OwnerUserId, + playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active), + playList.PlayListTracks + .Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) + .Sum(playListTrack => playListTrack.Track.DurationSeconds))); + } } diff --git a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs index 1d29ec1..6ae2c03 100644 --- a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs @@ -11,70 +11,71 @@ using Musify.Application.Services; using Musify.Application.Shared; using Musify.Domain.ValueObjects; -namespace Musify.Application.PlayLists; - -public record UpdatePlayListCommand( - long UserId, - Guid PlayListId, - string? NewName, - string? NewDescription, - Guid? NewPictureIntentId, - PlayListVisibility? NewVisibility = null) - : ICommand>; - -public class UpdatePlayListCommandHandler( - IEventBus eventBus, - IDatabase database, - UploadIntentValidator uploadIntentValidator, - ILogger logger, - PlayListConfiguration playListConfiguration) - : ICommandHandler> +namespace Musify.Application.PlayLists { - public async ValueTask> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken) + public record UpdatePlayListCommand( + long UserId, + Guid PlayListId, + string? NewName, + string? NewDescription, + Guid? NewPictureIntentId, + PlayListVisibility? NewVisibility = null) + : ICommand>; + + public class UpdatePlayListCommandHandler( + IEventBus eventBus, + IDatabase database, + UploadIntentValidator uploadIntentValidator, + ILogger logger, + PlayListConfiguration playListConfiguration) + : ICommandHandler> { - var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); - if (found.IsError) - return found.Errors; - - var playList = found.Value; - - if (!string.IsNullOrWhiteSpace(request.NewName)) + public async ValueTask> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken) { - playList.Name = request.NewName.Trim(); - playList.NormalizedName = TextNormalizer.Normalize(request.NewName); + var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; + + var playList = found.Value; + + if (!string.IsNullOrWhiteSpace(request.NewName)) + { + playList.Name = request.NewName.Trim(); + playList.NormalizedName = TextNormalizer.Normalize(request.NewName); + } + + // null means "unchanged"; a blank description clears it. + if (request.NewDescription != null) + playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim(); + + if (request.NewVisibility is { } visibility) + playList.Visibility = visibility; + + if (request.NewPictureIntentId is { } intentId) + { + var prepared = await PictureSourceChange.PrepareAsync( + uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken); + if (prepared.IsError) + return prepared.Errors; + + var picture = prepared.Value; + playList.Pictures = picture.Pictures; + + await eventBus.PublishAsync( + new UpdatePlayListPictureSourceEvent( + playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), + cancellationToken); + } + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Updated playlist {PlayListId}", playList.Id); + + return await database.PlayLists + .AsNoTracking() + .Where(p => p.Id == playList.Id) + .SelectResponse() + .SingleAsync(cancellationToken); } - - // null means "unchanged"; a blank description clears it. - if (request.NewDescription != null) - playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim(); - - if (request.NewVisibility is { } visibility) - playList.Visibility = visibility; - - if (request.NewPictureIntentId is { } intentId) - { - var prepared = await PictureSourceChange.PrepareAsync( - uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken); - if (prepared.IsError) - return prepared.Errors; - - var picture = prepared.Value; - playList.Pictures = picture.Pictures; - - await eventBus.PublishAsync( - new UpdatePlayListPictureSourceEvent( - playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), - cancellationToken); - } - - await database.SaveChangesAsync(cancellationToken); - - logger.LogInformation("Updated playlist {PlayListId}", playList.Id); - - return await database.PlayLists - .AsNoTracking() - .Where(p => p.Id == playList.Id) - .SelectResponse() - .SingleAsync(cancellationToken); } } diff --git a/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs b/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs index 6786cf7..fb50164 100644 --- a/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs +++ b/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs @@ -2,34 +2,35 @@ using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; -namespace Musify.Application.Services; - -/// Builds the stream URL and signed ticket for a processed track. It never touches the database. -public sealed class TrackStreamIssuer( - IStreamTicketService ticketService, - TrackConfiguration trackConfiguration, - StreamGatewayConfiguration streamGatewayConfiguration, - PlaybackConfiguration playbackConfiguration) +namespace Musify.Application.Services { - public TrackStreamResponse Issue(string audioFolderName, long? userId, Guid? listenId) + /// Builds the stream URL and signed ticket for a processed track. It never touches the database. + public sealed class TrackStreamIssuer( + IStreamTicketService ticketService, + TrackConfiguration trackConfiguration, + StreamGatewayConfiguration streamGatewayConfiguration, + PlaybackConfiguration playbackConfiguration) { - var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName); - var ticket = ticketService.IssueTicket(userId, $"{folderPath}/", AnonymousFragmentBytes(userId)); + public TrackStreamResponse Issue(string audioFolderName, long? userId, Guid? listenId) + { + var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName); + var ticket = ticketService.IssueTicket(userId, $"{folderPath}/", AnonymousFragmentBytes(userId)); - var manifestUrl = string.Join('/', - streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'), - "media", - folderPath, - streamGatewayConfiguration.AudioFileName); + var manifestUrl = string.Join('/', + streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'), + "media", + folderPath, + streamGatewayConfiguration.AudioFileName); - return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds, listenId); - } + return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds, listenId); + } - private long? AnonymousFragmentBytes(long? userId) - { - if (userId != null || playbackConfiguration.AnonymousFragmentSeconds <= 0) - return null; + private long? AnonymousFragmentBytes(long? userId) + { + if (userId != null || playbackConfiguration.AnonymousFragmentSeconds <= 0) + return null; - return (long)playbackConfiguration.AnonymousFragmentSeconds * playbackConfiguration.EstimatedAudioBytesPerSecond; + return (long)playbackConfiguration.AnonymousFragmentSeconds * playbackConfiguration.EstimatedAudioBytesPerSecond; + } } } diff --git a/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs b/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs index 70adca1..1e36869 100644 --- a/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs +++ b/backend/Core/Musify.Application/Services/UploadIntentIssuer.cs @@ -8,126 +8,127 @@ using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Services; - -public sealed record UploadRequest( - UploadIntentPurpose Purpose, - PictureRoutes Routes, - string FileType, - string ContentType, - long? ExpectedSizeBytes); - -public sealed record IssuedUpload( - Guid IntentId, - string Key, - string ObjectName, - string ContentType, - string UploadUrl); - -/// Issues pre-signed upload URLs and the upload intents that track them, checking the user quota first. -public sealed class UploadIntentIssuer( - IDatabase database, - IStorageService storageService, - UploadIntentValidator uploadIntentValidator, - ApplicationStorageConfiguration storageConfiguration, - UploadIntentConfiguration uploadIntentConfiguration, - ILogger logger) +namespace Musify.Application.Services { - public async Task>> IssueAsync( - long userId, - IReadOnlyList requests, - CancellationToken cancellationToken) + public sealed record UploadRequest( + UploadIntentPurpose Purpose, + PictureRoutes Routes, + string FileType, + string ContentType, + long? ExpectedSizeBytes); + + public sealed record IssuedUpload( + Guid IntentId, + string Key, + string ObjectName, + string ContentType, + string UploadUrl); + + /// Issues pre-signed upload URLs and the upload intents that track them, checking the user quota first. + public sealed class UploadIntentIssuer( + IDatabase database, + IStorageService storageService, + UploadIntentValidator uploadIntentValidator, + ApplicationStorageConfiguration storageConfiguration, + UploadIntentConfiguration uploadIntentConfiguration, + ILogger logger) { - var userExists = await database.Users - .AsNoTracking() - .AnyAsync(user => user.Id == userId, cancellationToken); - if (!userExists) - return AppErrors.NotFound("User", userId); - - var planned = requests - .Select(request => - { - var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}"; - var key = request.Routes.BuildTempPath(uploadIntentConfiguration.TempRootPrefix, userId, objectName); - var size = request.ExpectedSizeBytes ?? DefaultSize(request.Purpose); - return (Request: request, ObjectName: objectName, Key: key, Size: size); - }) - .ToList(); - - await using var transaction = await database.BeginTransactionAsync( - System.Data.IsolationLevel.Serializable, cancellationToken); - - var quota = await uploadIntentValidator.CheckQuotaAsync( - userId, planned.Sum(item => item.Size), planned.Count, cancellationToken); - if (quota.IsError) - return quota.Errors; - - var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds); - var expiresAt = DateTime.UtcNow + expiresIn; - var issued = new List(planned.Count); - - foreach (var item in planned) + public async Task>> IssueAsync( + long userId, + IReadOnlyList requests, + CancellationToken cancellationToken) { - var uploadUrl = await storageService.GetUploadUrlAsync( - storageConfiguration.Bucket, item.Key, item.Request.ContentType, expiresIn, cancellationToken: cancellationToken); + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(user => user.Id == userId, cancellationToken); + if (!userExists) + return AppErrors.NotFound("User", userId); - var intent = new UploadIntent + var planned = requests + .Select(request => + { + var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}"; + var key = request.Routes.BuildTempPath(uploadIntentConfiguration.TempRootPrefix, userId, objectName); + var size = request.ExpectedSizeBytes ?? DefaultSize(request.Purpose); + return (Request: request, ObjectName: objectName, Key: key, Size: size); + }) + .ToList(); + + await using var transaction = await database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, cancellationToken); + + var quota = await uploadIntentValidator.CheckQuotaAsync( + userId, planned.Sum(item => item.Size), planned.Count, cancellationToken); + if (quota.IsError) + return quota.Errors; + + var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds); + var expiresAt = DateTime.UtcNow + expiresIn; + var issued = new List(planned.Count); + + foreach (var item in planned) { - UserId = userId, - Bucket = storageConfiguration.Bucket, - Key = item.Key, - ObjectName = item.ObjectName, - ContentType = item.Request.ContentType, - ExpectedSizeBytes = item.Size, - Purpose = item.Request.Purpose, - ExpiresAt = expiresAt, - }; + var uploadUrl = await storageService.GetUploadUrlAsync( + storageConfiguration.Bucket, item.Key, item.Request.ContentType, expiresIn, cancellationToken: cancellationToken); - await database.UploadIntents.AddAsync(intent, cancellationToken); - issued.Add(new IssuedUpload(intent.Id, item.Key, item.ObjectName, item.Request.ContentType, uploadUrl)); + var intent = new UploadIntent + { + UserId = userId, + Bucket = storageConfiguration.Bucket, + Key = item.Key, + ObjectName = item.ObjectName, + ContentType = item.Request.ContentType, + ExpectedSizeBytes = item.Size, + Purpose = item.Request.Purpose, + ExpiresAt = expiresAt, + }; + + await database.UploadIntents.AddAsync(intent, cancellationToken); + issued.Add(new IssuedUpload(intent.Id, item.Key, item.ObjectName, item.Request.ContentType, uploadUrl)); + } + + await database.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + + logger.LogInformation("Issued {Count} upload intents for user {UserId} ({Purposes})", + issued.Count, userId, string.Join('/', planned.Select(item => item.Request.Purpose))); + + return issued; } - await database.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); + public async Task> IssueOneAsync(long userId, UploadRequest request, CancellationToken cancellationToken) + { + var issued = await IssueAsync(userId, [request], cancellationToken); + return issued.IsError ? issued.Errors : issued.Value[0]; + } - logger.LogInformation("Issued {Count} upload intents for user {UserId} ({Purposes})", - issued.Count, userId, string.Join('/', planned.Select(item => item.Request.Purpose))); + public async Task> IssuePictureAsync( + long userId, + UploadIntentPurpose purpose, + IPictureOwnerConfiguration owner, + string fileType, + string contentType, + long? expectedSizeBytes, + CancellationToken cancellationToken) + { + var issued = await IssueOneAsync( + userId, new UploadRequest(purpose, owner.Routes, fileType, contentType, expectedSizeBytes), cancellationToken); + if (issued.IsError) + return issued.Errors; - return issued; + return new PictureUploadResponse( + issued.Value.IntentId, + storageConfiguration.Bucket, + issued.Value.Key, + issued.Value.ObjectName, + issued.Value.ContentType, + uploadIntentConfiguration.UploadUrlExpiresInSeconds, + issued.Value.UploadUrl); + } + + private long DefaultSize(UploadIntentPurpose purpose) => + purpose == UploadIntentPurpose.TrackAudio + ? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes + : uploadIntentConfiguration.DefaultExpectedPictureSizeBytes; } - - public async Task> IssueOneAsync(long userId, UploadRequest request, CancellationToken cancellationToken) - { - var issued = await IssueAsync(userId, [request], cancellationToken); - return issued.IsError ? issued.Errors : issued.Value[0]; - } - - public async Task> IssuePictureAsync( - long userId, - UploadIntentPurpose purpose, - IPictureOwnerConfiguration owner, - string fileType, - string contentType, - long? expectedSizeBytes, - CancellationToken cancellationToken) - { - var issued = await IssueOneAsync( - userId, new UploadRequest(purpose, owner.Routes, fileType, contentType, expectedSizeBytes), cancellationToken); - if (issued.IsError) - return issued.Errors; - - return new PictureUploadResponse( - issued.Value.IntentId, - storageConfiguration.Bucket, - issued.Value.Key, - issued.Value.ObjectName, - issued.Value.ContentType, - uploadIntentConfiguration.UploadUrlExpiresInSeconds, - issued.Value.UploadUrl); - } - - private long DefaultSize(UploadIntentPurpose purpose) => - purpose == UploadIntentPurpose.TrackAudio - ? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes - : uploadIntentConfiguration.DefaultExpectedPictureSizeBytes; } diff --git a/backend/Core/Musify.Application/Services/UploadIntentValidator.cs b/backend/Core/Musify.Application/Services/UploadIntentValidator.cs index 4888ea7..3855124 100644 --- a/backend/Core/Musify.Application/Services/UploadIntentValidator.cs +++ b/backend/Core/Musify.Application/Services/UploadIntentValidator.cs @@ -6,72 +6,73 @@ using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Services; - -public sealed class UploadIntentValidator( - IDatabase database, - IStorageService storageService, - UploadIntentConfiguration config) +namespace Musify.Application.Services { - public async Task> CheckQuotaAsync( - long userId, - long requiredBytes, - int requiredIntentCount, - CancellationToken cancellationToken) + public sealed class UploadIntentValidator( + IDatabase database, + IStorageService storageService, + UploadIntentConfiguration config) { - var now = DateTime.UtcNow; - var pictureDefault = config.DefaultExpectedPictureSizeBytes; - var audioDefault = config.DefaultExpectedAudioSizeBytes; + public async Task> CheckQuotaAsync( + long userId, + long requiredBytes, + int requiredIntentCount, + CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + var pictureDefault = config.DefaultExpectedPictureSizeBytes; + var audioDefault = config.DefaultExpectedAudioSizeBytes; - var active = await database.UploadIntents - .AsNoTracking() - .Where(intent => intent.UserId == userId && intent.Status == UploadIntentStatus.Issued && intent.ExpiresAt > now) - .GroupBy(_ => 1) - .Select(group => new - { - Count = group.Count(), - Bytes = group.Sum(intent => intent.ExpectedSizeBytes - ?? (intent.Purpose == UploadIntentPurpose.TrackAudio ? audioDefault : pictureDefault)) - }) - .SingleOrDefaultAsync(cancellationToken); + var active = await database.UploadIntents + .AsNoTracking() + .Where(intent => intent.UserId == userId && intent.Status == UploadIntentStatus.Issued && intent.ExpiresAt > now) + .GroupBy(_ => 1) + .Select(group => new + { + Count = group.Count(), + Bytes = group.Sum(intent => intent.ExpectedSizeBytes + ?? (intent.Purpose == UploadIntentPurpose.TrackAudio ? audioDefault : pictureDefault)) + }) + .SingleOrDefaultAsync(cancellationToken); - if ((active?.Count ?? 0) + requiredIntentCount > config.MaxActiveUploadIntentsPerUser) - return Error.Validation(description: "Upload intent limit exceeded. Wait for existing uploads to complete or expire."); + if ((active?.Count ?? 0) + requiredIntentCount > config.MaxActiveUploadIntentsPerUser) + return Error.Validation(description: "Upload intent limit exceeded. Wait for existing uploads to complete or expire."); - if ((active?.Bytes ?? 0) + requiredBytes > config.MaxActiveUploadBytesPerUser) - return Error.Validation(description: "Upload byte quota exceeded. Wait for existing uploads to complete or expire."); + if ((active?.Bytes ?? 0) + requiredBytes > config.MaxActiveUploadBytesPerUser) + return Error.Validation(description: "Upload byte quota exceeded. Wait for existing uploads to complete or expire."); - return Result.Success; - } + return Result.Success; + } - public async Task> ValidateAndLoadAsync( - Guid intentId, - long userId, - UploadIntentPurpose purpose, - CancellationToken cancellationToken) - { - var intent = await database.UploadIntents - .FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken); + public async Task> ValidateAndLoadAsync( + Guid intentId, + long userId, + UploadIntentPurpose purpose, + CancellationToken cancellationToken) + { + var intent = await database.UploadIntents + .FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken); - if (intent == null || intent.UserId != userId) - return AppErrors.NotFound("UploadIntent", intentId); + if (intent == null || intent.UserId != userId) + return AppErrors.NotFound("UploadIntent", intentId); - if (intent.Purpose != purpose) - return Error.Validation(description: "Upload intent was issued for a different purpose."); + if (intent.Purpose != purpose) + return Error.Validation(description: "Upload intent was issued for a different purpose."); - if (intent.IsConsumed) - return AppErrors.Conflict("UploadIntent.Consumed", "Upload intent has already been consumed."); + if (intent.IsConsumed) + return AppErrors.Conflict("UploadIntent.Consumed", "Upload intent has already been consumed."); - if (intent.IsExpiredAt(DateTime.UtcNow)) - return Error.Validation(description: "Upload intent has expired."); + if (intent.IsExpiredAt(DateTime.UtcNow)) + return Error.Validation(description: "Upload intent has expired."); - var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken); - if (metadata == null) - return Error.NotFound("UploadIntent.ObjectMissing", "Uploaded object not found in storage. Upload the file first."); + var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken); + if (metadata == null) + return Error.NotFound("UploadIntent.ObjectMissing", "Uploaded object not found in storage. Upload the file first."); - if (metadata.ContentLength > config.MaxUploadBytes) - return Error.Validation(description: $"Uploaded file exceeds the maximum allowed size of {config.MaxUploadBytes} bytes."); + if (metadata.ContentLength > config.MaxUploadBytes) + return Error.Validation(description: $"Uploaded file exceeds the maximum allowed size of {config.MaxUploadBytes} bytes."); - return intent; + return intent; + } } } diff --git a/backend/Core/Musify.Application/Shared/AppErrors.cs b/backend/Core/Musify.Application/Shared/AppErrors.cs index 326d420..2bec4a7 100644 --- a/backend/Core/Musify.Application/Shared/AppErrors.cs +++ b/backend/Core/Musify.Application/Shared/AppErrors.cs @@ -1,15 +1,16 @@ using ErrorOr; -namespace Musify.Application.Shared; - -public static class AppErrors +namespace Musify.Application.Shared { - public static Error NotFound(string entity, object id) => - Error.NotFound($"{entity}.NotFound", $"{entity} {id} was not found."); + public static class AppErrors + { + public static Error NotFound(string entity, object id) => + Error.NotFound($"{entity}.NotFound", $"{entity} {id} was not found."); - public static Error Forbidden(string entity, object id) => - Error.Forbidden($"{entity}.Forbidden", $"You do not have permission to modify {entity} {id}."); + public static Error Forbidden(string entity, object id) => + Error.Forbidden($"{entity}.Forbidden", $"You do not have permission to modify {entity} {id}."); - public static Error Conflict(string code, string description) => - Error.Conflict(code, description); + public static Error Conflict(string code, string description) => + Error.Conflict(code, description); + } } diff --git a/backend/Core/Musify.Application/Shared/ImageSize.cs b/backend/Core/Musify.Application/Shared/ImageSize.cs index 0a9ebb4..b6a699e 100644 --- a/backend/Core/Musify.Application/Shared/ImageSize.cs +++ b/backend/Core/Musify.Application/Shared/ImageSize.cs @@ -1,11 +1,12 @@ -namespace Musify.Application.Shared; +namespace Musify.Application.Shared +{ + public record ImageSize( + string SavePath, + int Width, + int Height); -public record ImageSize( - string SavePath, - int Width, - int Height); - -public record ImageSizes( - ImageSize Small, - ImageSize Medium, - ImageSize Large); + public record ImageSizes( + ImageSize Small, + ImageSize Medium, + ImageSize Large); +} diff --git a/backend/Core/Musify.Application/Shared/OwnershipExtensions.cs b/backend/Core/Musify.Application/Shared/OwnershipExtensions.cs index 5ad6072..af06273 100644 --- a/backend/Core/Musify.Application/Shared/OwnershipExtensions.cs +++ b/backend/Core/Musify.Application/Shared/OwnershipExtensions.cs @@ -3,26 +3,27 @@ using Microsoft.EntityFrameworkCore; using Musify.Domain.Abstractions; using Musify.Domain.ValueObjects; -namespace Musify.Application.Shared; - -public static class OwnershipExtensions +namespace Musify.Application.Shared { - /// - /// Loads an active, tracked entity owned by : NotFound when it is missing - /// or not active, Forbidden when someone else owns it. - /// - public static async Task> FindOwnedAsync( - this DbSet set, Guid id, long userId, CancellationToken cancellationToken) - where T : class, IOwnedEntity, IHasLifeCycle + public static class OwnershipExtensions { - var entity = await set.SingleOrDefaultAsync( - e => e.Id == id && e.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); + /// + /// Loads an active, tracked entity owned by : NotFound when it is missing + /// or not active, Forbidden when someone else owns it. + /// + public static async Task> FindOwnedAsync( + this DbSet set, Guid id, long userId, CancellationToken cancellationToken) + where T : class, IOwnedEntity, IHasLifeCycle + { + var entity = await set.SingleOrDefaultAsync( + e => e.Id == id && e.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken); - if (entity == null) - return AppErrors.NotFound(typeof(T).Name, id); + if (entity == null) + return AppErrors.NotFound(typeof(T).Name, id); - return entity.OwnerUserId == userId - ? entity - : AppErrors.Forbidden(typeof(T).Name, id); + return entity.OwnerUserId == userId + ? entity + : AppErrors.Forbidden(typeof(T).Name, id); + } } } diff --git a/backend/Core/Musify.Application/Shared/Pagination.cs b/backend/Core/Musify.Application/Shared/Pagination.cs index 451669e..a58addf 100644 --- a/backend/Core/Musify.Application/Shared/Pagination.cs +++ b/backend/Core/Musify.Application/Shared/Pagination.cs @@ -1,45 +1,46 @@ using Microsoft.EntityFrameworkCore; -namespace Musify.Application.Shared; - -public record PageRequest(int PageNumber = 1, int PageSize = 20) +namespace Musify.Application.Shared { - public const int MaxPageSize = 100; -} - -public record PaginatedResponse( - IReadOnlyList Items, - int PageNumber, - int PageSize, - int PageCount, - int TotalItemCount, - bool HasNextPage, - bool HasPreviousPage); - -public static class PaginationExtensions -{ - /// Counts and pages the same (already filtered, ordered and projected) query. - public static async Task> ToPaginatedAsync( - this IQueryable query, PageRequest page, CancellationToken cancellationToken) + public record PageRequest(int PageNumber = 1, int PageSize = 20) { - var pageNumber = Math.Max(page.PageNumber, 1); - var pageSize = Math.Clamp(page.PageSize, 1, PageRequest.MaxPageSize); + public const int MaxPageSize = 100; + } - var totalCount = await query.CountAsync(cancellationToken); - var items = await query - .Skip((pageNumber - 1) * pageSize) - .Take(pageSize) - .ToListAsync(cancellationToken); + public record PaginatedResponse( + IReadOnlyList Items, + int PageNumber, + int PageSize, + int PageCount, + int TotalItemCount, + bool HasNextPage, + bool HasPreviousPage); - var pageCount = (int)Math.Ceiling(totalCount / (double)pageSize); + public static class PaginationExtensions + { + /// Counts and pages the same (already filtered, ordered and projected) query. + public static async Task> ToPaginatedAsync( + this IQueryable query, PageRequest page, CancellationToken cancellationToken) + { + var pageNumber = Math.Max(page.PageNumber, 1); + var pageSize = Math.Clamp(page.PageSize, 1, PageRequest.MaxPageSize); - return new PaginatedResponse( - items, - pageNumber, - pageSize, - pageCount, - totalCount, - HasNextPage: pageNumber < pageCount, - HasPreviousPage: pageNumber > 1 && pageCount > 0); + var totalCount = await query.CountAsync(cancellationToken); + var items = await query + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + var pageCount = (int)Math.Ceiling(totalCount / (double)pageSize); + + return new PaginatedResponse( + items, + pageNumber, + pageSize, + pageCount, + totalCount, + HasNextPage: pageNumber < pageCount, + HasPreviousPage: pageNumber > 1 && pageCount > 0); + } } } diff --git a/backend/Core/Musify.Application/Shared/StorageKey.cs b/backend/Core/Musify.Application/Shared/StorageKey.cs index 4837207..82ddca8 100644 --- a/backend/Core/Musify.Application/Shared/StorageKey.cs +++ b/backend/Core/Musify.Application/Shared/StorageKey.cs @@ -1,11 +1,12 @@ -namespace Musify.Application.Shared; - -public static class StorageKey +namespace Musify.Application.Shared { - public static string Combine(params string[] segments) + public static class StorageKey { - return string.Join('/', segments - .Where(static s => !string.IsNullOrWhiteSpace(s)) - .Select(static s => s.Trim().Trim('/', '\\'))); + public static string Combine(params string[] segments) + { + return string.Join('/', segments + .Where(static s => !string.IsNullOrWhiteSpace(s)) + .Select(static s => s.Trim().Trim('/', '\\'))); + } } } diff --git a/backend/Core/Musify.Application/Shared/TextNormalizer.cs b/backend/Core/Musify.Application/Shared/TextNormalizer.cs index e0c3e46..3611c12 100644 --- a/backend/Core/Musify.Application/Shared/TextNormalizer.cs +++ b/backend/Core/Musify.Application/Shared/TextNormalizer.cs @@ -1,6 +1,7 @@ -namespace Musify.Application.Shared; - -public static class TextNormalizer +namespace Musify.Application.Shared { - public static string Normalize(string value) => value.Trim().ToUpperInvariant(); + public static class TextNormalizer + { + public static string Normalize(string value) => value.Trim().ToUpperInvariant(); + } } diff --git a/backend/Core/Musify.Application/Tracks/CreateTrack.cs b/backend/Core/Musify.Application/Tracks/CreateTrack.cs index 52ec80d..ad734de 100644 --- a/backend/Core/Musify.Application/Tracks/CreateTrack.cs +++ b/backend/Core/Musify.Application/Tracks/CreateTrack.cs @@ -11,103 +11,104 @@ using Musify.Application.Tracks.Responses; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Tracks; - -public record CreateTrackCommand( - long UserId, - string Title, - Guid PictureIntentId, - Guid AudioIntentId, - IReadOnlyCollection Tags, - bool IsExplicit = false) - : ICommand>; - -public class CreateTrackCommandHandler( - IDatabase database, - IEventBus eventBus, - UploadIntentValidator uploadIntentValidator, - ILogger logger, - TrackConfiguration trackConfiguration) - : ICommandHandler> +namespace Musify.Application.Tracks { - public async ValueTask> Handle(CreateTrackCommand request, CancellationToken cancellationToken) + public record CreateTrackCommand( + long UserId, + string Title, + Guid PictureIntentId, + Guid AudioIntentId, + IReadOnlyCollection Tags, + bool IsExplicit = false) + : ICommand>; + + public class CreateTrackCommandHandler( + IDatabase database, + IEventBus eventBus, + UploadIntentValidator uploadIntentValidator, + ILogger logger, + TrackConfiguration trackConfiguration) + : ICommandHandler> { - var user = await database.Users - .SingleOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); - if (user == null) + public async ValueTask> Handle(CreateTrackCommand request, CancellationToken cancellationToken) { - logger.LogWarning("User {UserId} not found", request.UserId); - return Error.NotFound(description: $"User {request.UserId} not found"); - } - - var distinctTags = request.Tags.Distinct().ToList(); - - if (request.PictureIntentId == request.AudioIntentId) - return Error.Validation(description: "Picture and audio must use different upload intents."); - - var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync( - request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken); - if (pictureValidation.IsError) - return pictureValidation.Errors; - - var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync( - request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, cancellationToken); - if (audioValidation.IsError) - return audioValidation.Errors; - - var pictureIntent = pictureValidation.Value; - var audioIntent = audioValidation.Value; - - var finalPictureKey = trackConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); - var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName); - var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString()); - - var trackEntity = new Track - { - Title = request.Title.Trim(), - NormalizedTitle = TextNormalizer.Normalize(request.Title), - OwnerUserId = user.Id, - Owner = user, - IsExplicit = request.IsExplicit, - Pictures = new TrackPictures + var user = await database.Users + .SingleOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); + if (user == null) { - OriginalName = pictureIntent.ObjectName, - SmallName = trackConfiguration.Routes.PresetSmallPicture, - MediumName = trackConfiguration.Routes.PresetMediumPicture, - LargeName = trackConfiguration.Routes.PresetLargePicture, - ProcessingStatus = ProcessingStatus.Pending - }, - Audio = new TrackAudio - { - OriginalName = audioIntent.ObjectName, - TranscodeStatus = ProcessingStatus.Pending + logger.LogWarning("User {UserId} not found", request.UserId); + return Error.NotFound(description: $"User {request.UserId} not found"); } - }; - trackEntity.Tags = distinctTags - .Select(tag => new TrackTag { TrackId = trackEntity.Id, Tag = tag }) - .ToList(); + var distinctTags = request.Tags.Distinct().ToList(); - await database.Tracks.AddAsync(trackEntity, cancellationToken); + if (request.PictureIntentId == request.AudioIntentId) + return Error.Validation(description: "Picture and audio must use different upload intents."); + + var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync( + request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken); + if (pictureValidation.IsError) + return pictureValidation.Errors; + + var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync( + request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, cancellationToken); + if (audioValidation.IsError) + return audioValidation.Errors; + + var pictureIntent = pictureValidation.Value; + var audioIntent = audioValidation.Value; + + var finalPictureKey = trackConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); + var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName); + var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString()); + + var trackEntity = new Track + { + Title = request.Title.Trim(), + NormalizedTitle = TextNormalizer.Normalize(request.Title), + OwnerUserId = user.Id, + Owner = user, + IsExplicit = request.IsExplicit, + Pictures = new TrackPictures + { + OriginalName = pictureIntent.ObjectName, + SmallName = trackConfiguration.Routes.PresetSmallPicture, + MediumName = trackConfiguration.Routes.PresetMediumPicture, + LargeName = trackConfiguration.Routes.PresetLargePicture, + ProcessingStatus = ProcessingStatus.Pending + }, + Audio = new TrackAudio + { + OriginalName = audioIntent.ObjectName, + TranscodeStatus = ProcessingStatus.Pending + } + }; + + trackEntity.Tags = distinctTags + .Select(tag => new TrackTag { TrackId = trackEntity.Id, Tag = tag }) + .ToList(); + + await database.Tracks.AddAsync(trackEntity, cancellationToken); - var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes); - await eventBus.PublishAsync( - new CreateTrackResourcesEvent( - trackEntity.Id, - pictureIntent.Id, - audioIntent.Id, - pictureIntent.Bucket, - pictureIntent.Key, - finalPictureKey, - audioIntent.Key, - finalAudioKey, - audioProcessedFolderKey, - sizes), - cancellationToken); + var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes); + await eventBus.PublishAsync( + new CreateTrackResourcesEvent( + trackEntity.Id, + pictureIntent.Id, + audioIntent.Id, + pictureIntent.Bucket, + pictureIntent.Key, + finalPictureKey, + audioIntent.Key, + finalAudioKey, + audioProcessedFolderKey, + sizes), + cancellationToken); - await database.SaveChangesAsync(cancellationToken); + await database.SaveChangesAsync(cancellationToken); - return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0); + return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0); + } } } diff --git a/backend/Core/Musify.Application/Tracks/DeleteStaleUncountedListens.cs b/backend/Core/Musify.Application/Tracks/DeleteStaleUncountedListens.cs index 7cdfaef..bdd7c39 100644 --- a/backend/Core/Musify.Application/Tracks/DeleteStaleUncountedListens.cs +++ b/backend/Core/Musify.Application/Tracks/DeleteStaleUncountedListens.cs @@ -3,30 +3,31 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; -namespace Musify.Application.Tracks; - -public record DeleteStaleUncountedListensCommand : ICommand; - -public class DeleteStaleUncountedListensCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler +namespace Musify.Application.Tracks { - public async ValueTask Handle(DeleteStaleUncountedListensCommand request, CancellationToken cancellationToken) + public record DeleteStaleUncountedListensCommand : ICommand; + + public class DeleteStaleUncountedListensCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler { - var cutoff = DateTime.UtcNow.AddDays(-ListeningRules.UncountedRetentionDays); + public async ValueTask Handle(DeleteStaleUncountedListensCommand request, CancellationToken cancellationToken) + { + var cutoff = DateTime.UtcNow.AddDays(-ListeningRules.UncountedRetentionDays); - var stale = await database.ListeningHistories - .Where(l => !l.IsCounted && l.ListenedAt < cutoff) - .ToListAsync(cancellationToken); + var stale = await database.ListeningHistories + .Where(l => !l.IsCounted && l.ListenedAt < cutoff) + .ToListAsync(cancellationToken); - if (stale.Count == 0) - return 0; + if (stale.Count == 0) + return 0; - database.ListeningHistories.RemoveRange(stale); - await database.SaveChangesAsync(cancellationToken); + database.ListeningHistories.RemoveRange(stale); + await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Deleted {Count} uncounted listens older than {Days} days", stale.Count, ListeningRules.UncountedRetentionDays); - return stale.Count; + logger.LogInformation("Deleted {Count} uncounted listens older than {Days} days", stale.Count, ListeningRules.UncountedRetentionDays); + return stale.Count; + } } } diff --git a/backend/Core/Musify.Application/Tracks/DeleteTrack.cs b/backend/Core/Musify.Application/Tracks/DeleteTrack.cs index bde1aa1..b7c772a 100644 --- a/backend/Core/Musify.Application/Tracks/DeleteTrack.cs +++ b/backend/Core/Musify.Application/Tracks/DeleteTrack.cs @@ -6,36 +6,37 @@ using Musify.Application.Events; using Musify.Application.Shared; using Musify.Domain.ValueObjects; -namespace Musify.Application.Tracks; - -public record DeleteTrackCommand( - long UserId, - Guid TrackId) - : ICommand>; - -public class DeleteTrackCommandHandler( - IDatabase database, - IEventBus eventBus, - ILogger logger) - : ICommandHandler> +namespace Musify.Application.Tracks { - public async ValueTask> Handle(DeleteTrackCommand request, CancellationToken cancellationToken) + public record DeleteTrackCommand( + long UserId, + Guid TrackId) + : ICommand>; + + public class DeleteTrackCommandHandler( + IDatabase database, + IEventBus eventBus, + ILogger logger) + : ICommandHandler> { - var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken); - if (found.IsError) - return found.Errors; + public async ValueTask> Handle(DeleteTrackCommand request, CancellationToken cancellationToken) + { + var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken); + if (found.IsError) + return found.Errors; - var track = found.Value; - if (track.Audio.IsInProgress) - return AppErrors.Conflict("Track.Processing", "Track is currently being processed and cannot be deleted."); + var track = found.Value; + if (track.Audio.IsInProgress) + return AppErrors.Conflict("Track.Processing", "Track is currently being processed and cannot be deleted."); - track.LifeCycleStatus = LifeCycleStatus.Removing; + track.LifeCycleStatus = LifeCycleStatus.Removing; - await eventBus.PublishAsync(new DeleteTrackEvent(track.Id, request.UserId), cancellationToken); - await database.SaveChangesAsync(cancellationToken); + await eventBus.PublishAsync(new DeleteTrackEvent(track.Id, request.UserId), cancellationToken); + await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Marked track {TrackId} as removing", request.TrackId); + logger.LogInformation("Marked track {TrackId} as removing", request.TrackId); - return Result.Success; + return Result.Success; + } } } diff --git a/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs b/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs index 4048ba3..55e34af 100644 --- a/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs @@ -4,23 +4,24 @@ using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; using Musify.Domain.ValueObjects; -namespace Musify.Application.Tracks; - -public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery; - -public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database) - : IQueryHandler +namespace Musify.Application.Tracks { - public async ValueTask Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken) + public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery; + + public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database) + : IQueryHandler { - return await database.ListeningHistories - .AsNoTracking() - .Include(t => t.Track.Owner) - .Include(t => t.Track.Tags) - .Where(t => t.UserId == query.UserId && t.IsCounted && t.Track.LifeCycleStatus == LifeCycleStatus.Active) - .OrderByDescending(t => t.ListenedAt) - .ThenBy(t => t.Id) - .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted))) - .FirstOrDefaultAsync(cancellationToken); + public async ValueTask Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken) + { + return await database.ListeningHistories + .AsNoTracking() + .Include(t => t.Track.Owner) + .Include(t => t.Track.Tags) + .Where(t => t.UserId == query.UserId && t.IsCounted && t.Track.LifeCycleStatus == LifeCycleStatus.Active) + .OrderByDescending(t => t.ListenedAt) + .ThenBy(t => t.Id) + .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted))) + .FirstOrDefaultAsync(cancellationToken); + } } } diff --git a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs index 2a44802..48a797a 100644 --- a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs +++ b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs @@ -3,37 +3,38 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; -namespace Musify.Application.Tracks; - -public record GetListeningHistoryQuery(long UserId) : IQuery>; - -public class GetListeningHistoryQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Tracks { - public const int ListSize = 15; + public record GetListeningHistoryQuery(long UserId) : IQuery>; - public async ValueTask> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken) + public class GetListeningHistoryQueryHandler(IDatabase database) + : IQueryHandler> { - var recentTrackIds = await database.ListeningHistories - .AsNoTracking() - .Where(l => l.UserId == query.UserId && l.IsCounted) - .GroupBy(l => l.TrackId) - .OrderByDescending(g => g.Max(l => l.ListenedAt)) - .ThenBy(g => g.Key) - .Select(g => g.Key) - .Take(ListSize) - .ToListAsync(cancellationToken); + public const int ListSize = 15; - var tracksById = await database.Tracks - .AsNoTracking() - .Active() - .Where(t => recentTrackIds.Contains(t.Id)) - .SelectResponse() - .ToDictionaryAsync(t => t.Id, cancellationToken); + public async ValueTask> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken) + { + var recentTrackIds = await database.ListeningHistories + .AsNoTracking() + .Where(l => l.UserId == query.UserId && l.IsCounted) + .GroupBy(l => l.TrackId) + .OrderByDescending(g => g.Max(l => l.ListenedAt)) + .ThenBy(g => g.Key) + .Select(g => g.Key) + .Take(ListSize) + .ToListAsync(cancellationToken); - return recentTrackIds - .Where(id => tracksById.ContainsKey(id)) - .Select(id => tracksById[id]) - .ToList(); + var tracksById = await database.Tracks + .AsNoTracking() + .Active() + .Where(t => recentTrackIds.Contains(t.Id)) + .SelectResponse() + .ToDictionaryAsync(t => t.Id, cancellationToken); + + return recentTrackIds + .Where(id => tracksById.ContainsKey(id)) + .Select(id => tracksById[id]) + .ToList(); + } } } diff --git a/backend/Core/Musify.Application/Tracks/GetTrackById.cs b/backend/Core/Musify.Application/Tracks/GetTrackById.cs index 3614661..c6a5d0a 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackById.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackById.cs @@ -5,25 +5,26 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -namespace Musify.Application.Tracks; - -public record GetTrackByIdQuery(Guid TrackId) - : IQuery>; - -public class GetTrackByIdQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Tracks { - public async ValueTask> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken) - { - var track = await database.Tracks - .AsNoTracking() - .Active() - .Where(t => t.Id == request.TrackId) - .SelectResponse() - .SingleOrDefaultAsync(cancellationToken); + public record GetTrackByIdQuery(Guid TrackId) + : IQuery>; - return track == null - ? AppErrors.NotFound("Track", request.TrackId) - : track; + public class GetTrackByIdQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken) + { + var track = await database.Tracks + .AsNoTracking() + .Active() + .Where(t => t.Id == request.TrackId) + .SelectResponse() + .SingleOrDefaultAsync(cancellationToken); + + return track == null + ? AppErrors.NotFound("Track", request.TrackId) + : track; + } } } diff --git a/backend/Core/Musify.Application/Tracks/GetTracks.cs b/backend/Core/Musify.Application/Tracks/GetTracks.cs index 6120e24..e8ed4e9 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracks.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracks.cs @@ -5,33 +5,34 @@ using Musify.Application.Shared; using Musify.Application.Tracks.Responses; using Musify.Domain.ValueObjects; -namespace Musify.Application.Tracks; - -public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null) - : IQuery>; - -public class GetTracksQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Tracks { - public async ValueTask> Handle(GetTracksQuery request, CancellationToken cancellationToken) + public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null) + : IQuery>; + + public class GetTracksQueryHandler(IDatabase database) + : IQueryHandler> { - var tracksQuery = database.Tracks - .AsNoTracking() - .Active(); - - if (!string.IsNullOrWhiteSpace(request.Name)) + public async ValueTask> Handle(GetTracksQuery request, CancellationToken cancellationToken) { - var normalizedName = TextNormalizer.Normalize(request.Name); - tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName)); + var tracksQuery = database.Tracks + .AsNoTracking() + .Active(); + + if (!string.IsNullOrWhiteSpace(request.Name)) + { + var normalizedName = TextNormalizer.Normalize(request.Name); + tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName)); + } + + if (request.Genre is { } genre) + tracksQuery = tracksQuery.Where(track => track.Tags.Any(tag => tag.Tag == genre)); + + return await tracksQuery + .OrderByDescending(track => track.CreatedAt) + .ThenBy(track => track.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } - - if (request.Genre is { } genre) - tracksQuery = tracksQuery.Where(track => track.Tags.Any(tag => tag.Tag == genre)); - - return await tracksQuery - .OrderByDescending(track => track.CreatedAt) - .ThenBy(track => track.Id) - .SelectResponse() - .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs index 5ec82be..43b799d 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs @@ -5,35 +5,36 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -namespace Musify.Application.Tracks; - -public record GetTracksByUserIdQuery( - long UserId, - string? Name, - int PageNumber, - int PageSize) - : IQuery>>; - -public class GetTracksByUserIdQueryHandler(IDatabase database) - : IQueryHandler>> +namespace Musify.Application.Tracks { - public async ValueTask>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken) + public record GetTracksByUserIdQuery( + long UserId, + string? Name, + int PageNumber, + int PageSize) + : IQuery>>; + + public class GetTracksByUserIdQueryHandler(IDatabase database) + : IQueryHandler>> { - var tracksQuery = database.Tracks - .AsNoTracking() - .Active() - .Where(track => track.OwnerUserId == request.UserId); - - if (!string.IsNullOrWhiteSpace(request.Name)) + public async ValueTask>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken) { - var normalizedName = TextNormalizer.Normalize(request.Name); - tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName)); - } + var tracksQuery = database.Tracks + .AsNoTracking() + .Active() + .Where(track => track.OwnerUserId == request.UserId); - return await tracksQuery - .OrderByDescending(track => track.CreatedAt) - .ThenBy(track => track.Id) - .SelectResponse() - .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + if (!string.IsNullOrWhiteSpace(request.Name)) + { + var normalizedName = TextNormalizer.Normalize(request.Name); + tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName)); + } + + return await tracksQuery + .OrderByDescending(track => track.CreatedAt) + .ThenBy(track => track.Id) + .SelectResponse() + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + } } } diff --git a/backend/Core/Musify.Application/Tracks/ListeningRules.cs b/backend/Core/Musify.Application/Tracks/ListeningRules.cs index 2992575..146ef1e 100644 --- a/backend/Core/Musify.Application/Tracks/ListeningRules.cs +++ b/backend/Core/Musify.Application/Tracks/ListeningRules.cs @@ -1,13 +1,14 @@ -namespace Musify.Application.Tracks; - -public static class ListeningRules +namespace Musify.Application.Tracks { - public const double CountedThresholdSeconds = 30; - public const double CountedThresholdFraction = 0.5; - public const double DurationToleranceSeconds = 2; - public const double ClockToleranceSeconds = 5; - public const int UncountedRetentionDays = 8; + public static class ListeningRules + { + public const double CountedThresholdSeconds = 30; + public const double CountedThresholdFraction = 0.5; + public const double DurationToleranceSeconds = 2; + public const double ClockToleranceSeconds = 5; + public const int UncountedRetentionDays = 8; - public static double CountedThreshold(double durationSeconds) => - Math.Min(CountedThresholdSeconds, durationSeconds * CountedThresholdFraction); + public static double CountedThreshold(double durationSeconds) => + Math.Min(CountedThresholdSeconds, durationSeconds * CountedThresholdFraction); + } } diff --git a/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs index 9ced6ff..b00696e 100644 --- a/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs +++ b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs @@ -4,38 +4,39 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Shared; -namespace Musify.Application.Tracks; - -public record RecordListeningProgressCommand( - long UserId, - Guid ListenId, - double PlayedSeconds) - : ICommand>; - -public class RecordListeningProgressCommandHandler(IDatabase database) - : ICommandHandler> +namespace Musify.Application.Tracks { - public async ValueTask> Handle(RecordListeningProgressCommand request, CancellationToken cancellationToken) + public record RecordListeningProgressCommand( + long UserId, + Guid ListenId, + double PlayedSeconds) + : ICommand>; + + public class RecordListeningProgressCommandHandler(IDatabase database) + : ICommandHandler> { - var listen = await database.ListeningHistories - .Include(l => l.Track) - .SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken); + public async ValueTask> Handle(RecordListeningProgressCommand request, CancellationToken cancellationToken) + { + var listen = await database.ListeningHistories + .Include(l => l.Track) + .SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken); - if (listen == null) - return AppErrors.NotFound("Listen", request.ListenId); + if (listen == null) + return AppErrors.NotFound("Listen", request.ListenId); - var now = DateTime.UtcNow; - var durationCap = listen.Track.DurationSeconds + ListeningRules.DurationToleranceSeconds; - var clockCap = (now - listen.ListenedAt).TotalSeconds + ListeningRules.ClockToleranceSeconds; - var accepted = Math.Min(request.PlayedSeconds, Math.Min(durationCap, clockCap)); + var now = DateTime.UtcNow; + var durationCap = listen.Track.DurationSeconds + ListeningRules.DurationToleranceSeconds; + var clockCap = (now - listen.ListenedAt).TotalSeconds + ListeningRules.ClockToleranceSeconds; + var accepted = Math.Min(request.PlayedSeconds, Math.Min(durationCap, clockCap)); - listen.PlayedSeconds = Math.Max(listen.PlayedSeconds ?? 0, accepted); - listen.LastProgressAt = now; - listen.IsCounted = listen.IsCounted - || listen.PlayedSeconds >= ListeningRules.CountedThreshold(listen.Track.DurationSeconds); + listen.PlayedSeconds = Math.Max(listen.PlayedSeconds ?? 0, accepted); + listen.LastProgressAt = now; + listen.IsCounted = listen.IsCounted + || listen.PlayedSeconds >= ListeningRules.CountedThreshold(listen.Track.DurationSeconds); - await database.SaveChangesAsync(cancellationToken); + await database.SaveChangesAsync(cancellationToken); - return Result.Success; + return Result.Success; + } } } diff --git a/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs b/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs index 5c51124..98fcad9 100644 --- a/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs +++ b/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs @@ -5,53 +5,54 @@ using Musify.Application.Services; using Musify.Application.Tracks.Responses; using Musify.Domain.ValueObjects; -namespace Musify.Application.Tracks; - -public record RequestTrackUploadUrlsCommand( - long UserId, - string PictureFileType, - string PictureContentType, - string AudioFileType, - string AudioContentType, - long? ExpectedPictureSizeBytes = null, - long? ExpectedAudioSizeBytes = null) - : ICommand>; - -public class RequestTrackUploadUrlsCommandHandler( - UploadIntentIssuer issuer, - ApplicationStorageConfiguration storageConfiguration, - TrackConfiguration trackConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) - : ICommandHandler> +namespace Musify.Application.Tracks { - public async ValueTask> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken) + public record RequestTrackUploadUrlsCommand( + long UserId, + string PictureFileType, + string PictureContentType, + string AudioFileType, + string AudioContentType, + long? ExpectedPictureSizeBytes = null, + long? ExpectedAudioSizeBytes = null) + : ICommand>; + + public class RequestTrackUploadUrlsCommandHandler( + UploadIntentIssuer issuer, + ApplicationStorageConfiguration storageConfiguration, + TrackConfiguration trackConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) + : ICommandHandler> { - var routes = trackConfiguration.Routes; - var issued = await issuer.IssueAsync( - request.UserId, - [ - new UploadRequest(UploadIntentPurpose.TrackPicture, routes, request.PictureFileType, request.PictureContentType, request.ExpectedPictureSizeBytes), - new UploadRequest(UploadIntentPurpose.TrackAudio, routes, request.AudioFileType, request.AudioContentType, request.ExpectedAudioSizeBytes) - ], - cancellationToken); - if (issued.IsError) - return issued.Errors; + public async ValueTask> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken) + { + var routes = trackConfiguration.Routes; + var issued = await issuer.IssueAsync( + request.UserId, + [ + new UploadRequest(UploadIntentPurpose.TrackPicture, routes, request.PictureFileType, request.PictureContentType, request.ExpectedPictureSizeBytes), + new UploadRequest(UploadIntentPurpose.TrackAudio, routes, request.AudioFileType, request.AudioContentType, request.ExpectedAudioSizeBytes) + ], + cancellationToken); + if (issued.IsError) + return issued.Errors; - var picture = issued.Value[0]; - var audio = issued.Value[1]; + var picture = issued.Value[0]; + var audio = issued.Value[1]; - return new TrackUploadUrlsResponse( - picture.IntentId, - audio.IntentId, - storageConfiguration.Bucket, - picture.Key, - picture.ObjectName, - picture.ContentType, - picture.UploadUrl, - audio.Key, - audio.ObjectName, - audio.ContentType, - audio.UploadUrl, - uploadIntentConfiguration.UploadUrlExpiresInSeconds); + return new TrackUploadUrlsResponse( + picture.IntentId, + audio.IntentId, + storageConfiguration.Bucket, + picture.Key, + picture.ObjectName, + picture.ContentType, + picture.UploadUrl, + audio.Key, + audio.ObjectName, + audio.ContentType, + audio.UploadUrl, + uploadIntentConfiguration.UploadUrlExpiresInSeconds); + } } } diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs index da88141..95dd13a 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs @@ -1,32 +1,33 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Tracks.Responses; - -public record TrackApplicationResponse( - Guid Id, - string Title, - string? Artist, - ProcessingStatus AudioStatus, - double Duration, - int ListensCount, - DateTime CreatedAt, - DateTime UpdatedAt, - long OwnerUserId, - IReadOnlyCollection Tags, - bool IsExplicit) +namespace Musify.Application.Tracks.Responses { - public static TrackApplicationResponse FromEntity(Track track, int listensCount) => - new( - track.Id, - track.Title, - track.Owner.Name, - track.Audio.TranscodeStatus, - track.DurationSeconds, - listensCount, - track.CreatedAt, - track.UpdatedAt, - track.OwnerUserId, - track.Tags.Select(tag => tag.Tag).ToList(), - track.IsExplicit); + public record TrackApplicationResponse( + Guid Id, + string Title, + string? Artist, + ProcessingStatus AudioStatus, + double Duration, + int ListensCount, + DateTime CreatedAt, + DateTime UpdatedAt, + long OwnerUserId, + IReadOnlyCollection Tags, + bool IsExplicit) + { + public static TrackApplicationResponse FromEntity(Track track, int listensCount) => + new( + track.Id, + track.Title, + track.Owner.Name, + track.Audio.TranscodeStatus, + track.DurationSeconds, + listensCount, + track.CreatedAt, + track.UpdatedAt, + track.OwnerUserId, + track.Tags.Select(tag => tag.Tag).ToList(), + track.IsExplicit); + } } diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs index 33fa4f8..df228d3 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackProjections.cs @@ -1,24 +1,25 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Tracks.Responses; - -public static class TrackProjections +namespace Musify.Application.Tracks.Responses { - public static IQueryable Active(this IQueryable query) => - query.Where(track => track.LifeCycleStatus == LifeCycleStatus.Active); + public static class TrackProjections + { + public static IQueryable Active(this IQueryable query) => + query.Where(track => track.LifeCycleStatus == LifeCycleStatus.Active); - public static IQueryable SelectResponse(this IQueryable query) => - query.Select(track => new TrackApplicationResponse( - 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)); + public static IQueryable SelectResponse(this IQueryable query) => + query.Select(track => new TrackApplicationResponse( + 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)); + } } diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackStreamResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackStreamResponse.cs index 146e1ac..024edf7 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackStreamResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackStreamResponse.cs @@ -1,7 +1,8 @@ -namespace Musify.Application.Tracks.Responses; - -public record TrackStreamResponse( - string ManifestUrl, - string Ticket, - int ExpiresInSeconds, - Guid? ListenId = null); +namespace Musify.Application.Tracks.Responses +{ + public record TrackStreamResponse( + string ManifestUrl, + string Ticket, + int ExpiresInSeconds, + Guid? ListenId = null); +} diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackUploadUrlsResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackUploadUrlsResponse.cs index e5b6106..8e1f5fb 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackUploadUrlsResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackUploadUrlsResponse.cs @@ -1,15 +1,16 @@ -namespace Musify.Application.Tracks.Responses; - -public record TrackUploadUrlsResponse( - Guid PictureIntentId, - Guid AudioIntentId, - string Bucket, - string PictureKey, - string PictureName, - string PictureContentType, - string PictureUploadUrl, - string AudioKey, - string AudioName, - string AudioContentType, - string AudioUploadUrl, - int ExpiresInSeconds); +namespace Musify.Application.Tracks.Responses +{ + public record TrackUploadUrlsResponse( + Guid PictureIntentId, + Guid AudioIntentId, + string Bucket, + string PictureKey, + string PictureName, + string PictureContentType, + string PictureUploadUrl, + string AudioKey, + string AudioName, + string AudioContentType, + string AudioUploadUrl, + int ExpiresInSeconds); +} diff --git a/backend/Core/Musify.Application/Tracks/StartListening.cs b/backend/Core/Musify.Application/Tracks/StartListening.cs index ef3aeb1..73a51c7 100644 --- a/backend/Core/Musify.Application/Tracks/StartListening.cs +++ b/backend/Core/Musify.Application/Tracks/StartListening.cs @@ -11,46 +11,47 @@ using Musify.Application.Tracks.Responses; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Tracks; - -/// Starts a listen: records it for signed-in users and issues the stream ticket. -public record StartListeningCommand(Guid TrackId, long? UserId) - : ICommand>; - -public class StartListeningCommandHandler( - IDatabase database, - TrackStreamIssuer streamIssuer, - PlaybackConfiguration playbackConfiguration, - ILogger logger) - : ICommandHandler> +namespace Musify.Application.Tracks { - public async ValueTask> Handle(StartListeningCommand request, CancellationToken cancellationToken) + /// Starts a listen: records it for signed-in users and issues the stream ticket. + public record StartListeningCommand(Guid TrackId, long? UserId) + : ICommand>; + + public class StartListeningCommandHandler( + IDatabase database, + TrackStreamIssuer streamIssuer, + PlaybackConfiguration playbackConfiguration, + ILogger logger) + : ICommandHandler> { - if (request.UserId == null && !playbackConfiguration.AllowAnonymousListening) - return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening."); - - var track = await database.Tracks.AsNoTracking() - .Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active) - .Select(t => new { t.Id, t.Audio }) - .SingleOrDefaultAsync(cancellationToken); - - if (track == null) - return AppErrors.NotFound("Track", request.TrackId); - - if (!track.Audio.IsProcessed) - return AppErrors.Conflict("Track.AudioNotReady", "Track audio is not available for streaming yet."); - - Guid? listenId = null; - if (request.UserId is { } userId) + public async ValueTask> Handle(StartListeningCommand request, CancellationToken cancellationToken) { - var listen = new ListeningHistory { UserId = userId, TrackId = track.Id }; - await database.ListeningHistories.AddAsync(listen, cancellationToken); - await database.SaveChangesAsync(cancellationToken); - listenId = listen.Id; + if (request.UserId == null && !playbackConfiguration.AllowAnonymousListening) + return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening."); + + var track = await database.Tracks.AsNoTracking() + .Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active) + .Select(t => new { t.Id, t.Audio }) + .SingleOrDefaultAsync(cancellationToken); + + if (track == null) + return AppErrors.NotFound("Track", request.TrackId); + + if (!track.Audio.IsProcessed) + return AppErrors.Conflict("Track.AudioNotReady", "Track audio is not available for streaming yet."); + + Guid? listenId = null; + if (request.UserId is { } userId) + { + var listen = new ListeningHistory { UserId = userId, TrackId = track.Id }; + await database.ListeningHistories.AddAsync(listen, cancellationToken); + await database.SaveChangesAsync(cancellationToken); + listenId = listen.Id; + } + + logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId?.ToString(CultureInfo.InvariantCulture) ?? "(anonymous)"); + + return streamIssuer.Issue(track.Audio.FolderName, request.UserId, listenId); } - - logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId?.ToString(CultureInfo.InvariantCulture) ?? "(anonymous)"); - - return streamIssuer.Issue(track.Audio.FolderName, request.UserId, listenId); } } diff --git a/backend/Core/Musify.Application/UploadIntents/DeleteStaleTempUploads.cs b/backend/Core/Musify.Application/UploadIntents/DeleteStaleTempUploads.cs index 05fc48d..a27baa6 100644 --- a/backend/Core/Musify.Application/UploadIntents/DeleteStaleTempUploads.cs +++ b/backend/Core/Musify.Application/UploadIntents/DeleteStaleTempUploads.cs @@ -3,36 +3,37 @@ using Microsoft.Extensions.Logging; using Musify.Application.Configuration; using Musify.Application.Contracts; -namespace Musify.Application.UploadIntents; - -/// Deletes temporary upload objects that nobody claimed within the retention window. -public record DeleteStaleTempUploadsCommand : ICommand; - -public class DeleteStaleTempUploadsCommandHandler( - IStorageService storageService, - ApplicationStorageConfiguration storageConfiguration, - UploadIntentConfiguration uploadIntentConfiguration, - ILogger logger) - : ICommandHandler +namespace Musify.Application.UploadIntents { - public async ValueTask Handle(DeleteStaleTempUploadsCommand request, CancellationToken cancellationToken) + /// Deletes temporary upload objects that nobody claimed within the retention window. + public record DeleteStaleTempUploadsCommand : ICommand; + + public class DeleteStaleTempUploadsCommandHandler( + IStorageService storageService, + ApplicationStorageConfiguration storageConfiguration, + UploadIntentConfiguration uploadIntentConfiguration, + ILogger logger) + : ICommandHandler { - var cutOff = DateTime.UtcNow.AddDays(-uploadIntentConfiguration.TempUploadsRetentionDays); - var deleted = 0; - - await foreach (var (key, lastModified) in storageService.ListObjectsAsync( - storageConfiguration.Bucket, uploadIntentConfiguration.TempRootPrefix, cancellationToken)) + public async ValueTask Handle(DeleteStaleTempUploadsCommand request, CancellationToken cancellationToken) { - if (lastModified >= cutOff) - continue; + var cutOff = DateTime.UtcNow.AddDays(-uploadIntentConfiguration.TempUploadsRetentionDays); + var deleted = 0; - await storageService.RemoveFileAsync(storageConfiguration.Bucket, key, cancellationToken); - deleted++; + await foreach (var (key, lastModified) in storageService.ListObjectsAsync( + storageConfiguration.Bucket, uploadIntentConfiguration.TempRootPrefix, cancellationToken)) + { + if (lastModified >= cutOff) + continue; + + await storageService.RemoveFileAsync(storageConfiguration.Bucket, key, cancellationToken); + deleted++; + } + + if (deleted > 0) + logger.LogInformation("Deleted {Deleted} orphaned temp uploads", deleted); + + return deleted; } - - if (deleted > 0) - logger.LogInformation("Deleted {Deleted} orphaned temp uploads", deleted); - - return deleted; } } diff --git a/backend/Core/Musify.Application/UploadIntents/ExpireUploadIntents.cs b/backend/Core/Musify.Application/UploadIntents/ExpireUploadIntents.cs index 9b6e3d2..1759bfd 100644 --- a/backend/Core/Musify.Application/UploadIntents/ExpireUploadIntents.cs +++ b/backend/Core/Musify.Application/UploadIntents/ExpireUploadIntents.cs @@ -5,42 +5,43 @@ using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Domain.ValueObjects; -namespace Musify.Application.UploadIntents; - -/// Marks overdue upload intents as expired and deletes the ones expired for longer than the retention window. -public record ExpireUploadIntentsCommand : ICommand; - -public class ExpireUploadIntentsCommandHandler( - IDatabase database, - UploadIntentConfiguration uploadIntentConfiguration, - ILogger logger) - : ICommandHandler +namespace Musify.Application.UploadIntents { - public async ValueTask Handle(ExpireUploadIntentsCommand request, CancellationToken cancellationToken) + /// Marks overdue upload intents as expired and deletes the ones expired for longer than the retention window. + public record ExpireUploadIntentsCommand : ICommand; + + public class ExpireUploadIntentsCommandHandler( + IDatabase database, + UploadIntentConfiguration uploadIntentConfiguration, + ILogger logger) + : ICommandHandler { - var now = DateTime.UtcNow; + public async ValueTask Handle(ExpireUploadIntentsCommand request, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; - var expiredIntents = await database.UploadIntents - .Where(intent => intent.Status == UploadIntentStatus.Issued && intent.ExpiresAt < now) - .ToListAsync(cancellationToken); + var expiredIntents = await database.UploadIntents + .Where(intent => intent.Status == UploadIntentStatus.Issued && intent.ExpiresAt < now) + .ToListAsync(cancellationToken); - foreach (var intent in expiredIntents) - intent.Status = UploadIntentStatus.Expired; + foreach (var intent in expiredIntents) + intent.Status = UploadIntentStatus.Expired; - var retentionCutoff = now.AddDays(-uploadIntentConfiguration.ExpiredIntentsRetentionDays); - var oldExpiredIntents = await database.UploadIntents - .Where(intent => intent.Status == UploadIntentStatus.Expired && intent.ExpiresAt < retentionCutoff) - .ToListAsync(cancellationToken); + var retentionCutoff = now.AddDays(-uploadIntentConfiguration.ExpiredIntentsRetentionDays); + var oldExpiredIntents = await database.UploadIntents + .Where(intent => intent.Status == UploadIntentStatus.Expired && intent.ExpiresAt < retentionCutoff) + .ToListAsync(cancellationToken); - database.UploadIntents.RemoveRange(oldExpiredIntents); + database.UploadIntents.RemoveRange(oldExpiredIntents); - if (expiredIntents.Count == 0 && oldExpiredIntents.Count == 0) - return 0; + if (expiredIntents.Count == 0 && oldExpiredIntents.Count == 0) + return 0; - await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Marked {Expired} upload intents as expired and deleted {Deleted} old ones", - expiredIntents.Count, oldExpiredIntents.Count); + await database.SaveChangesAsync(cancellationToken); + logger.LogInformation("Marked {Expired} upload intents as expired and deleted {Deleted} old ones", + expiredIntents.Count, oldExpiredIntents.Count); - return expiredIntents.Count + oldExpiredIntents.Count; + return expiredIntents.Count + oldExpiredIntents.Count; + } } } diff --git a/backend/Core/Musify.Application/Users/FollowUser.cs b/backend/Core/Musify.Application/Users/FollowUser.cs index bcb4441..029b2ee 100644 --- a/backend/Core/Musify.Application/Users/FollowUser.cs +++ b/backend/Core/Musify.Application/Users/FollowUser.cs @@ -6,46 +6,47 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Domain.Entities; -namespace Musify.Application.Users; - -public record FollowUserCommand(long FollowerId, long FollowedId) : ICommand>; - -public class FollowUserCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> +namespace Musify.Application.Users { - public async ValueTask> Handle(FollowUserCommand request, CancellationToken cancellationToken) + public record FollowUserCommand(long FollowerId, long FollowedId) : ICommand>; + + public class FollowUserCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> { - if (request.FollowerId == request.FollowedId) - return Error.Validation(description: "A user cannot follow themselves."); - - var followedExists = await database.Users - .AsNoTracking() - .AnyAsync(u => u.Id == request.FollowedId, cancellationToken); - if (!followedExists) - return AppErrors.NotFound("User", request.FollowedId); - - var alreadyFollowing = await database.UserFollows - .AsNoTracking() - .AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken); - if (alreadyFollowing) - return Result.Success; - - await database.UserFollows.AddAsync(new UserFollow + public async ValueTask> Handle(FollowUserCommand request, CancellationToken cancellationToken) { - FollowerId = request.FollowerId, - FollowedId = request.FollowedId - }, cancellationToken); + if (request.FollowerId == request.FollowedId) + return Error.Validation(description: "A user cannot follow themselves."); + + var followedExists = await database.Users + .AsNoTracking() + .AnyAsync(u => u.Id == request.FollowedId, cancellationToken); + if (!followedExists) + return AppErrors.NotFound("User", request.FollowedId); + + var alreadyFollowing = await database.UserFollows + .AsNoTracking() + .AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken); + if (alreadyFollowing) + return Result.Success; + + await database.UserFollows.AddAsync(new UserFollow + { + FollowerId = request.FollowerId, + FollowedId = request.FollowedId + }, cancellationToken); + + // A concurrent double click can hit the unique index; already following is a successful no-op. + var saved = await database.TrySaveChangesAsync( + AppErrors.Conflict("UserFollow.Duplicate", "Already following this user."), cancellationToken); + if (saved.IsError) + return Result.Success; + + logger.LogInformation("User {FollowerId} followed user {FollowedId}", request.FollowerId, request.FollowedId); - // A concurrent double click can hit the unique index; already following is a successful no-op. - var saved = await database.TrySaveChangesAsync( - AppErrors.Conflict("UserFollow.Duplicate", "Already following this user."), cancellationToken); - if (saved.IsError) return Result.Success; - - logger.LogInformation("User {FollowerId} followed user {FollowedId}", request.FollowerId, request.FollowedId); - - return Result.Success; + } } } diff --git a/backend/Core/Musify.Application/Users/FollowVisibility.cs b/backend/Core/Musify.Application/Users/FollowVisibility.cs index 573e1a3..b3c45a8 100644 --- a/backend/Core/Musify.Application/Users/FollowVisibility.cs +++ b/backend/Core/Musify.Application/Users/FollowVisibility.cs @@ -1,29 +1,30 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; -namespace Musify.Application.Users; - -public static class FollowVisibility +namespace Musify.Application.Users { - public static async Task CanViewFollowersAsync( - IDatabase database, - long? viewerId, - long userId, - CancellationToken cancellationToken) + public static class FollowVisibility { - if (viewerId is not { } viewer) - return false; + public static async Task CanViewFollowersAsync( + IDatabase database, + long? viewerId, + long userId, + CancellationToken cancellationToken) + { + if (viewerId is not { } viewer) + return false; - if (viewer == userId) - return true; + if (viewer == userId) + return true; - var mutualRows = await database.UserFollows - .AsNoTracking() - .CountAsync(f => - (f.FollowerId == viewer && f.FollowedId == userId) || - (f.FollowerId == userId && f.FollowedId == viewer), - cancellationToken); + var mutualRows = await database.UserFollows + .AsNoTracking() + .CountAsync(f => + (f.FollowerId == viewer && f.FollowedId == userId) || + (f.FollowerId == userId && f.FollowedId == viewer), + cancellationToken); - return mutualRows == 2; + return mutualRows == 2; + } } } diff --git a/backend/Core/Musify.Application/Users/GetListeningStats.cs b/backend/Core/Musify.Application/Users/GetListeningStats.cs index a1fcd1d..981c474 100644 --- a/backend/Core/Musify.Application/Users/GetListeningStats.cs +++ b/backend/Core/Musify.Application/Users/GetListeningStats.cs @@ -3,50 +3,51 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Users.Responses; -namespace Musify.Application.Users; - -public record GetListeningStatsQuery(long UserId) : IQuery; - -public class GetListeningStatsQueryHandler(IDatabase database) - : IQueryHandler +namespace Musify.Application.Users { - public async ValueTask Handle(GetListeningStatsQuery request, CancellationToken cancellationToken) + public record GetListeningStatsQuery(long UserId) : IQuery; + + public class GetListeningStatsQueryHandler(IDatabase database) + : IQueryHandler { - var weekAgo = DateTime.UtcNow.AddDays(-7); - - var thisWeekHistory = database.ListeningHistories - .AsNoTracking() - .Where(l => l.UserId == request.UserId && l.ListenedAt >= weekAgo); - - var tracksThisWeek = await thisWeekHistory - .Where(l => l.IsCounted) - .Select(l => l.TrackId) - .Distinct() - .CountAsync(cancellationToken); - - var secondsThisWeek = await thisWeekHistory - .Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => l.PlayedSeconds ?? (l.IsCounted ? t.DurationSeconds : 0)) - .SumAsync(cancellationToken); - - var listenedDates = await database.ListeningHistories - .AsNoTracking() - .Where(l => l.UserId == request.UserId && l.IsCounted) - .Select(l => l.ListenedAt.Date) - .Distinct() - .ToListAsync(cancellationToken); - - var listenedDateSet = listenedDates.ToHashSet(); - var streakDays = 0; - var day = DateTime.UtcNow.Date; - if (!listenedDateSet.Contains(day)) - day = day.AddDays(-1); - - while (listenedDateSet.Contains(day)) + public async ValueTask Handle(GetListeningStatsQuery request, CancellationToken cancellationToken) { - streakDays++; - day = day.AddDays(-1); - } + var weekAgo = DateTime.UtcNow.AddDays(-7); - return new ListeningStatsResponse(tracksThisWeek, (int)Math.Round(secondsThisWeek), streakDays); + var thisWeekHistory = database.ListeningHistories + .AsNoTracking() + .Where(l => l.UserId == request.UserId && l.ListenedAt >= weekAgo); + + var tracksThisWeek = await thisWeekHistory + .Where(l => l.IsCounted) + .Select(l => l.TrackId) + .Distinct() + .CountAsync(cancellationToken); + + var secondsThisWeek = await thisWeekHistory + .Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => l.PlayedSeconds ?? (l.IsCounted ? t.DurationSeconds : 0)) + .SumAsync(cancellationToken); + + var listenedDates = await database.ListeningHistories + .AsNoTracking() + .Where(l => l.UserId == request.UserId && l.IsCounted) + .Select(l => l.ListenedAt.Date) + .Distinct() + .ToListAsync(cancellationToken); + + var listenedDateSet = listenedDates.ToHashSet(); + var streakDays = 0; + var day = DateTime.UtcNow.Date; + if (!listenedDateSet.Contains(day)) + day = day.AddDays(-1); + + while (listenedDateSet.Contains(day)) + { + streakDays++; + day = day.AddDays(-1); + } + + return new ListeningStatsResponse(tracksThisWeek, (int)Math.Round(secondsThisWeek), streakDays); + } } } diff --git a/backend/Core/Musify.Application/Users/GetUserById.cs b/backend/Core/Musify.Application/Users/GetUserById.cs index 0d6eab3..dc009bc 100644 --- a/backend/Core/Musify.Application/Users/GetUserById.cs +++ b/backend/Core/Musify.Application/Users/GetUserById.cs @@ -5,24 +5,25 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Users.Responses; -namespace Musify.Application.Users; - -public record GetUserByIdQuery(long Id) - : IQuery>; - -public class GetUserByIdQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Users { - public async ValueTask> Handle(GetUserByIdQuery request, CancellationToken cancellationToken) - { - var user = await database.Users - .AsNoTracking() - .Where(u => u.Id == request.Id) - .Select(u => UserApplicationResponse.FromEntity(u)) - .SingleOrDefaultAsync(cancellationToken); + public record GetUserByIdQuery(long Id) + : IQuery>; - return user == null - ? AppErrors.NotFound("User", request.Id) - : user; + public class GetUserByIdQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetUserByIdQuery request, CancellationToken cancellationToken) + { + var user = await database.Users + .AsNoTracking() + .Where(u => u.Id == request.Id) + .Select(u => UserApplicationResponse.FromEntity(u)) + .SingleOrDefaultAsync(cancellationToken); + + return user == null + ? AppErrors.NotFound("User", request.Id) + : user; + } } } diff --git a/backend/Core/Musify.Application/Users/GetUserFollowers.cs b/backend/Core/Musify.Application/Users/GetUserFollowers.cs index 90284fb..9759352 100644 --- a/backend/Core/Musify.Application/Users/GetUserFollowers.cs +++ b/backend/Core/Musify.Application/Users/GetUserFollowers.cs @@ -5,36 +5,37 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Users.Responses; -namespace Musify.Application.Users; - -public record GetUserFollowersQuery(long? ViewerId, long UserId, int PageNumber, int PageSize) - : IQuery>>; - -public class GetUserFollowersQueryHandler(IDatabase database) - : IQueryHandler>> +namespace Musify.Application.Users { - public async ValueTask>> Handle(GetUserFollowersQuery request, CancellationToken cancellationToken) + public record GetUserFollowersQuery(long? ViewerId, long UserId, int PageNumber, int PageSize) + : IQuery>>; + + public class GetUserFollowersQueryHandler(IDatabase database) + : IQueryHandler>> { - if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken)) - return AppErrors.NotFound("User", request.UserId); + public async ValueTask>> Handle(GetUserFollowersQuery request, CancellationToken cancellationToken) + { + if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken)) + return AppErrors.NotFound("User", request.UserId); - if (!await FollowVisibility.CanViewFollowersAsync(database, request.ViewerId, request.UserId, cancellationToken)) - return AppErrors.Forbidden("User", request.UserId); + if (!await FollowVisibility.CanViewFollowersAsync(database, request.ViewerId, request.UserId, cancellationToken)) + return AppErrors.Forbidden("User", request.UserId); - var viewerId = request.ViewerId; + var viewerId = request.ViewerId; - return await database.UserFollows - .AsNoTracking() - .Where(f => f.FollowedId == request.UserId) - .OrderByDescending(f => f.CreatedAt) - .ThenBy(f => f.Id) - .Select(f => new UserSummaryResponse( - f.Follower.Id, - f.Follower.Name, - f.Follower.FirstName, - f.Follower.SecondName, - f.Follower.ProfilePictureUrl, - viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowerId))) - .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + return await database.UserFollows + .AsNoTracking() + .Where(f => f.FollowedId == request.UserId) + .OrderByDescending(f => f.CreatedAt) + .ThenBy(f => f.Id) + .Select(f => new UserSummaryResponse( + f.Follower.Id, + f.Follower.Name, + f.Follower.FirstName, + f.Follower.SecondName, + f.Follower.ProfilePictureUrl, + viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowerId))) + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + } } } diff --git a/backend/Core/Musify.Application/Users/GetUserFollowing.cs b/backend/Core/Musify.Application/Users/GetUserFollowing.cs index 50c7c1b..ae2cb84 100644 --- a/backend/Core/Musify.Application/Users/GetUserFollowing.cs +++ b/backend/Core/Musify.Application/Users/GetUserFollowing.cs @@ -5,33 +5,34 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Users.Responses; -namespace Musify.Application.Users; - -public record GetUserFollowingQuery(long? ViewerId, long UserId, int PageNumber, int PageSize) - : IQuery>>; - -public class GetUserFollowingQueryHandler(IDatabase database) - : IQueryHandler>> +namespace Musify.Application.Users { - public async ValueTask>> Handle(GetUserFollowingQuery request, CancellationToken cancellationToken) + public record GetUserFollowingQuery(long? ViewerId, long UserId, int PageNumber, int PageSize) + : IQuery>>; + + public class GetUserFollowingQueryHandler(IDatabase database) + : IQueryHandler>> { - if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken)) - return AppErrors.NotFound("User", request.UserId); + public async ValueTask>> Handle(GetUserFollowingQuery request, CancellationToken cancellationToken) + { + if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken)) + return AppErrors.NotFound("User", request.UserId); - var viewerId = request.ViewerId; + var viewerId = request.ViewerId; - return await database.UserFollows - .AsNoTracking() - .Where(f => f.FollowerId == request.UserId) - .OrderByDescending(f => f.CreatedAt) - .ThenBy(f => f.Id) - .Select(f => new UserSummaryResponse( - f.Followed.Id, - f.Followed.Name, - f.Followed.FirstName, - f.Followed.SecondName, - f.Followed.ProfilePictureUrl, - viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowedId))) - .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + return await database.UserFollows + .AsNoTracking() + .Where(f => f.FollowerId == request.UserId) + .OrderByDescending(f => f.CreatedAt) + .ThenBy(f => f.Id) + .Select(f => new UserSummaryResponse( + f.Followed.Id, + f.Followed.Name, + f.Followed.FirstName, + f.Followed.SecondName, + f.Followed.ProfilePictureUrl, + viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowedId))) + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); + } } } diff --git a/backend/Core/Musify.Application/Users/GetUserProfile.cs b/backend/Core/Musify.Application/Users/GetUserProfile.cs index cf9796e..51ad5dd 100644 --- a/backend/Core/Musify.Application/Users/GetUserProfile.cs +++ b/backend/Core/Musify.Application/Users/GetUserProfile.cs @@ -5,58 +5,59 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Users.Responses; -namespace Musify.Application.Users; - -public record GetUserProfileQuery(long UserId, long? ViewerId = null) : IQuery>; - -public class GetUserProfileQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Users { - public async ValueTask> Handle(GetUserProfileQuery request, CancellationToken cancellationToken) + public record GetUserProfileQuery(long UserId, long? ViewerId = null) : IQuery>; + + public class GetUserProfileQueryHandler(IDatabase database) + : IQueryHandler> { - var user = await database.Users - .AsNoTracking() - .Where(u => u.Id == request.UserId) - .Select(u => new - { - u.Id, - u.Name, - u.FirstName, - u.SecondName, - u.ProfilePictureUrl, - u.CreatedAt - }) - .SingleOrDefaultAsync(cancellationToken); - - if (user == null) - return AppErrors.NotFound("User", request.UserId); - - var followersCount = await database.UserFollows - .AsNoTracking() - .CountAsync(f => f.FollowedId == request.UserId, cancellationToken); - - var followingCount = await database.UserFollows - .AsNoTracking() - .CountAsync(f => f.FollowerId == request.UserId, cancellationToken); - - var isFollowing = request.ViewerId is { } viewerId && viewerId != request.UserId && - await database.UserFollows + public async ValueTask> Handle(GetUserProfileQuery request, CancellationToken cancellationToken) + { + var user = await database.Users .AsNoTracking() - .AnyAsync(f => f.FollowerId == viewerId && f.FollowedId == request.UserId, cancellationToken); + .Where(u => u.Id == request.UserId) + .Select(u => new + { + u.Id, + u.Name, + u.FirstName, + u.SecondName, + u.ProfilePictureUrl, + u.CreatedAt + }) + .SingleOrDefaultAsync(cancellationToken); - var canViewFollowers = await FollowVisibility.CanViewFollowersAsync( - database, request.ViewerId, request.UserId, cancellationToken); + if (user == null) + return AppErrors.NotFound("User", request.UserId); - return new UserProfileResponse( - user.Id, - user.Name, - user.FirstName, - user.SecondName, - user.ProfilePictureUrl, - user.CreatedAt, - followersCount, - followingCount, - isFollowing, - canViewFollowers); + var followersCount = await database.UserFollows + .AsNoTracking() + .CountAsync(f => f.FollowedId == request.UserId, cancellationToken); + + var followingCount = await database.UserFollows + .AsNoTracking() + .CountAsync(f => f.FollowerId == request.UserId, cancellationToken); + + var isFollowing = request.ViewerId is { } viewerId && viewerId != request.UserId && + await database.UserFollows + .AsNoTracking() + .AnyAsync(f => f.FollowerId == viewerId && f.FollowedId == request.UserId, cancellationToken); + + var canViewFollowers = await FollowVisibility.CanViewFollowersAsync( + database, request.ViewerId, request.UserId, cancellationToken); + + return new UserProfileResponse( + user.Id, + user.Name, + user.FirstName, + user.SecondName, + user.ProfilePictureUrl, + user.CreatedAt, + followersCount, + followingCount, + isFollowing, + canViewFollowers); + } } } diff --git a/backend/Core/Musify.Application/Users/GetUsers.cs b/backend/Core/Musify.Application/Users/GetUsers.cs index 1dcffd7..d839c73 100644 --- a/backend/Core/Musify.Application/Users/GetUsers.cs +++ b/backend/Core/Musify.Application/Users/GetUsers.cs @@ -4,37 +4,38 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Users.Responses; -namespace Musify.Application.Users; - -public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch, long? ViewerId = null) - : IQuery>; - -public class GetUsersQueryHandler(IDatabase database) - : IQueryHandler> +namespace Musify.Application.Users { - public async ValueTask> Handle(GetUsersQuery request, CancellationToken cancellationToken) + public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch, long? ViewerId = null) + : IQuery>; + + public class GetUsersQueryHandler(IDatabase database) + : IQueryHandler> { - var usersQuery = database.Users - .AsNoTracking(); - - if (!string.IsNullOrWhiteSpace(request.UsernameSearch)) + public async ValueTask> Handle(GetUsersQuery request, CancellationToken cancellationToken) { - var normalizedUsername = TextNormalizer.Normalize(request.UsernameSearch); - usersQuery = usersQuery.Where(user => user.NormalizedName.Contains(normalizedUsername)); + var usersQuery = database.Users + .AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(request.UsernameSearch)) + { + var normalizedUsername = TextNormalizer.Normalize(request.UsernameSearch); + usersQuery = usersQuery.Where(user => user.NormalizedName.Contains(normalizedUsername)); + } + + var viewerId = request.ViewerId; + + return await usersQuery + .OrderBy(user => user.Name) + .ThenBy(user => user.Id) + .Select(user => new UserSummaryResponse( + user.Id, + user.Name, + user.FirstName, + user.SecondName, + user.ProfilePictureUrl, + viewerId != null && database.UserFollows.Any(f => f.FollowerId == viewerId && f.FollowedId == user.Id))) + .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } - - var viewerId = request.ViewerId; - - return await usersQuery - .OrderBy(user => user.Name) - .ThenBy(user => user.Id) - .Select(user => new UserSummaryResponse( - user.Id, - user.Name, - user.FirstName, - user.SecondName, - user.ProfilePictureUrl, - viewerId != null && database.UserFollows.Any(f => f.FollowerId == viewerId && f.FollowedId == user.Id))) - .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); } } diff --git a/backend/Core/Musify.Application/Users/IsFollowingUser.cs b/backend/Core/Musify.Application/Users/IsFollowingUser.cs index 44b9ca9..a8e9a2a 100644 --- a/backend/Core/Musify.Application/Users/IsFollowingUser.cs +++ b/backend/Core/Musify.Application/Users/IsFollowingUser.cs @@ -2,15 +2,16 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; -namespace Musify.Application.Users; - -public record IsFollowingUserQuery(long FollowerId, long FollowedId) : IQuery; - -public class IsFollowingUserQueryHandler(IDatabase database) - : IQueryHandler +namespace Musify.Application.Users { - public ValueTask Handle(IsFollowingUserQuery request, CancellationToken cancellationToken) => - new(database.UserFollows - .AsNoTracking() - .AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken)); + public record IsFollowingUserQuery(long FollowerId, long FollowedId) : IQuery; + + public class IsFollowingUserQueryHandler(IDatabase database) + : IQueryHandler + { + public ValueTask Handle(IsFollowingUserQuery request, CancellationToken cancellationToken) => + new(database.UserFollows + .AsNoTracking() + .AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken)); + } } diff --git a/backend/Core/Musify.Application/Users/Responses/ListeningStatsResponse.cs b/backend/Core/Musify.Application/Users/Responses/ListeningStatsResponse.cs index c12d7ce..b632205 100644 --- a/backend/Core/Musify.Application/Users/Responses/ListeningStatsResponse.cs +++ b/backend/Core/Musify.Application/Users/Responses/ListeningStatsResponse.cs @@ -1,3 +1,4 @@ -namespace Musify.Application.Users.Responses; - -public record ListeningStatsResponse(int TracksThisWeek, int SecondsThisWeek, int StreakDays); +namespace Musify.Application.Users.Responses +{ + public record ListeningStatsResponse(int TracksThisWeek, int SecondsThisWeek, int StreakDays); +} diff --git a/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs b/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs index 2ab2b7b..a96a88a 100644 --- a/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs +++ b/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs @@ -1,25 +1,26 @@ using Musify.Domain.Entities; -namespace Musify.Application.Users.Responses; - -public record UserApplicationResponse( - long Id, - string Name, - string? FirstName, - string? SecondName, - string? ProfilePictureUrl, - DateTime CreatedAt, - DateTime UpdatedAt) +namespace Musify.Application.Users.Responses { - public static UserApplicationResponse FromEntity(User user) + public record UserApplicationResponse( + long Id, + string Name, + string? FirstName, + string? SecondName, + string? ProfilePictureUrl, + DateTime CreatedAt, + DateTime UpdatedAt) { - return new UserApplicationResponse( - user.Id, - user.Name, - user.FirstName, - user.SecondName, - user.ProfilePictureUrl, - user.CreatedAt, - user.UpdatedAt); + public static UserApplicationResponse FromEntity(User user) + { + return new UserApplicationResponse( + user.Id, + user.Name, + user.FirstName, + user.SecondName, + user.ProfilePictureUrl, + user.CreatedAt, + user.UpdatedAt); + } } } diff --git a/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs b/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs index 1c3bf41..de3252b 100644 --- a/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs +++ b/backend/Core/Musify.Application/Users/Responses/UserProfileResponse.cs @@ -1,14 +1,15 @@ -namespace Musify.Application.Users.Responses; - -public record UserProfileResponse( - long Id, - string Name, - string? FirstName, - string? SecondName, - string? ProfilePictureUrl, - DateTime CreatedAt, - int FollowersCount, - int FollowingCount, - bool IsFollowing, - bool CanViewFollowers); +namespace Musify.Application.Users.Responses +{ + public record UserProfileResponse( + long Id, + string Name, + string? FirstName, + string? SecondName, + string? ProfilePictureUrl, + DateTime CreatedAt, + int FollowersCount, + int FollowingCount, + bool IsFollowing, + bool CanViewFollowers); +} diff --git a/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs b/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs index 34651c6..cf08570 100644 --- a/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs +++ b/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs @@ -1,10 +1,11 @@ -namespace Musify.Application.Users.Responses; - -public record UserSummaryResponse( - long Id, - string Name, - string? FirstName, - string? SecondName, - string? ProfilePictureUrl, - bool IsFollowedByViewer); +namespace Musify.Application.Users.Responses +{ + public record UserSummaryResponse( + long Id, + string Name, + string? FirstName, + string? SecondName, + string? ProfilePictureUrl, + bool IsFollowedByViewer); +} diff --git a/backend/Core/Musify.Application/Users/SyncUser.cs b/backend/Core/Musify.Application/Users/SyncUser.cs index ff5c6ac..4b15cb9 100644 --- a/backend/Core/Musify.Application/Users/SyncUser.cs +++ b/backend/Core/Musify.Application/Users/SyncUser.cs @@ -6,52 +6,53 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Domain.Entities; -namespace Musify.Application.Users; - -public record SyncUserCommand( - long Id, - string Name, - string? FirstName, - string? SecondName, - string? ProfilePictureUrl) - : ICommand>; - -public class SyncUserCommandHandler(IDatabase database, ILogger logger) - : ICommandHandler> +namespace Musify.Application.Users { - public async ValueTask> Handle(SyncUserCommand request, CancellationToken cancellationToken) + public record SyncUserCommand( + long Id, + string Name, + string? FirstName, + string? SecondName, + string? ProfilePictureUrl) + : ICommand>; + + public class SyncUserCommandHandler(IDatabase database, ILogger logger) + : ICommandHandler> { - var user = await database.Users - .SingleOrDefaultAsync(u => u.Id == request.Id, cancellationToken); - - if (user == null) + public async ValueTask> Handle(SyncUserCommand request, CancellationToken cancellationToken) { - await database.Users.AddAsync(new User - { - Id = request.Id, - Name = request.Name, - NormalizedName = TextNormalizer.Normalize(request.Name), - FirstName = request.FirstName, - SecondName = request.SecondName, - ProfilePictureUrl = request.ProfilePictureUrl - }, cancellationToken); + var user = await database.Users + .SingleOrDefaultAsync(u => u.Id == request.Id, cancellationToken); - await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Provisioned user {UserId}", request.Id); + if (user == null) + { + await database.Users.AddAsync(new User + { + Id = request.Id, + Name = request.Name, + NormalizedName = TextNormalizer.Normalize(request.Name), + FirstName = request.FirstName, + SecondName = request.SecondName, + ProfilePictureUrl = request.ProfilePictureUrl + }, cancellationToken); + + await database.SaveChangesAsync(cancellationToken); + logger.LogInformation("Provisioned user {UserId}", request.Id); + + return Result.Success; + } + + user.Name = request.Name; + user.NormalizedName = TextNormalizer.Normalize(request.Name); + user.FirstName = request.FirstName; + user.SecondName = request.SecondName; + user.ProfilePictureUrl = request.ProfilePictureUrl; + + var written = await database.SaveChangesAsync(cancellationToken); + if (written > 0) + logger.LogInformation("Synced profile for user {UserId}", request.Id); return Result.Success; } - - user.Name = request.Name; - user.NormalizedName = TextNormalizer.Normalize(request.Name); - user.FirstName = request.FirstName; - user.SecondName = request.SecondName; - user.ProfilePictureUrl = request.ProfilePictureUrl; - - var written = await database.SaveChangesAsync(cancellationToken); - if (written > 0) - logger.LogInformation("Synced profile for user {UserId}", request.Id); - - return Result.Success; } } diff --git a/backend/Core/Musify.Application/Users/UnfollowUser.cs b/backend/Core/Musify.Application/Users/UnfollowUser.cs index 49c24d6..5d4ea87 100644 --- a/backend/Core/Musify.Application/Users/UnfollowUser.cs +++ b/backend/Core/Musify.Application/Users/UnfollowUser.cs @@ -4,28 +4,29 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; -namespace Musify.Application.Users; - -public record UnfollowUserCommand(long FollowerId, long FollowedId) : ICommand>; - -public class UnfollowUserCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> +namespace Musify.Application.Users { - public async ValueTask> Handle(UnfollowUserCommand request, CancellationToken cancellationToken) + public record UnfollowUserCommand(long FollowerId, long FollowedId) : ICommand>; + + public class UnfollowUserCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> { - var follow = await database.UserFollows - .SingleOrDefaultAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken); + public async ValueTask> Handle(UnfollowUserCommand request, CancellationToken cancellationToken) + { + var follow = await database.UserFollows + .SingleOrDefaultAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken); + + if (follow == null) + return Result.Success; + + database.UserFollows.Remove(follow); + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("User {FollowerId} unfollowed user {FollowedId}", request.FollowerId, request.FollowedId); - if (follow == null) return Result.Success; - - database.UserFollows.Remove(follow); - await database.SaveChangesAsync(cancellationToken); - - logger.LogInformation("User {FollowerId} unfollowed user {FollowedId}", request.FollowerId, request.FollowedId); - - return Result.Success; + } } } diff --git a/backend/Core/Musify.Domain/Abstractions/IAuditable.cs b/backend/Core/Musify.Domain/Abstractions/IAuditable.cs index b837a31..bbba395 100644 --- a/backend/Core/Musify.Domain/Abstractions/IAuditable.cs +++ b/backend/Core/Musify.Domain/Abstractions/IAuditable.cs @@ -1,8 +1,9 @@ -namespace Musify.Domain.Abstractions; - -public interface IAuditable +namespace Musify.Domain.Abstractions { - public DateTime CreatedAt { get; set; } + public interface IAuditable + { + public DateTime CreatedAt { get; set; } - public DateTime UpdatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } } diff --git a/backend/Core/Musify.Domain/Abstractions/IHasLifeCycle.cs b/backend/Core/Musify.Domain/Abstractions/IHasLifeCycle.cs index 6020728..0bcd88d 100644 --- a/backend/Core/Musify.Domain/Abstractions/IHasLifeCycle.cs +++ b/backend/Core/Musify.Domain/Abstractions/IHasLifeCycle.cs @@ -1,10 +1,11 @@ using Musify.Domain.ValueObjects; -namespace Musify.Domain.Abstractions; - -public interface IHasLifeCycle +namespace Musify.Domain.Abstractions { - public Guid Id { get; } + public interface IHasLifeCycle + { + public Guid Id { get; } - public LifeCycleStatus LifeCycleStatus { get; set; } + public LifeCycleStatus LifeCycleStatus { get; set; } + } } diff --git a/backend/Core/Musify.Domain/Abstractions/IOwnedEntity.cs b/backend/Core/Musify.Domain/Abstractions/IOwnedEntity.cs index d65c9fb..944c211 100644 --- a/backend/Core/Musify.Domain/Abstractions/IOwnedEntity.cs +++ b/backend/Core/Musify.Domain/Abstractions/IOwnedEntity.cs @@ -1,6 +1,7 @@ -namespace Musify.Domain.Abstractions; - -public interface IOwnedEntity +namespace Musify.Domain.Abstractions { - public long OwnerUserId { get; } + public interface IOwnedEntity + { + public long OwnerUserId { get; } + } } diff --git a/backend/Core/Musify.Domain/Entities/Album.cs b/backend/Core/Musify.Domain/Entities/Album.cs index 0cfeca1..8542293 100644 --- a/backend/Core/Musify.Domain/Entities/Album.cs +++ b/backend/Core/Musify.Domain/Entities/Album.cs @@ -3,37 +3,38 @@ using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; using Musify.Domain.ValueObjects; -namespace Musify.Domain.Entities; - -[Table("Albums")] -public class Album : IAuditable, IHasLifeCycle, IOwnedEntity +namespace Musify.Domain.Entities { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); + [Table("Albums")] + public class Album : IAuditable, IHasLifeCycle, IOwnedEntity + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - [MaxLength(200)] - public required string Title { get; set; } + [MaxLength(200)] + public required string Title { get; set; } - [MaxLength(200)] - public required string NormalizedTitle { get; set; } + [MaxLength(200)] + public required string NormalizedTitle { get; set; } - [MaxLength(256)] - public string? Description { get; set; } + [MaxLength(256)] + public string? Description { get; set; } - public int? ReleaseYear { get; set; } + public int? ReleaseYear { get; set; } - public EntityPictures? Pictures { get; set; } + public EntityPictures? Pictures { get; set; } - public required long OwnerUserId { get; set; } + public required long OwnerUserId { get; set; } - [ForeignKey(nameof(OwnerUserId))] - public User Owner { get; set; } = null!; + [ForeignKey(nameof(OwnerUserId))] + public User Owner { get; set; } = null!; - public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; + public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - public ICollection AlbumTracks { get; set; } = new List(); + public ICollection AlbumTracks { get; set; } = new List(); + } } diff --git a/backend/Core/Musify.Domain/Entities/AlbumHasTrack.cs b/backend/Core/Musify.Domain/Entities/AlbumHasTrack.cs index 3648390..cb90a5d 100644 --- a/backend/Core/Musify.Domain/Entities/AlbumHasTrack.cs +++ b/backend/Core/Musify.Domain/Entities/AlbumHasTrack.cs @@ -1,23 +1,24 @@ using System.ComponentModel.DataAnnotations.Schema; -namespace Musify.Domain.Entities; - -[Table("AlbumHasTrack")] -public class AlbumHasTrack +namespace Musify.Domain.Entities { - public Guid Id { get; set; } = Guid.NewGuid(); + [Table("AlbumHasTrack")] + public class AlbumHasTrack + { + public Guid Id { get; set; } = Guid.NewGuid(); - public required Guid AlbumId { get; set; } + public required Guid AlbumId { get; set; } - public required Guid TrackId { get; set; } + public required Guid TrackId { get; set; } - public int TrackNumber { get; set; } = 1; + public int TrackNumber { get; set; } = 1; - public DateTime AddedAt { get; set; } = DateTime.UtcNow; + public DateTime AddedAt { get; set; } = DateTime.UtcNow; - [ForeignKey(nameof(AlbumId))] - public Album Album { get; set; } = null!; + [ForeignKey(nameof(AlbumId))] + public Album Album { get; set; } = null!; - [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } = null!; + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } = null!; + } } diff --git a/backend/Core/Musify.Domain/Entities/ListeningHistory.cs b/backend/Core/Musify.Domain/Entities/ListeningHistory.cs index 4a1afd1..48ecefe 100644 --- a/backend/Core/Musify.Domain/Entities/ListeningHistory.cs +++ b/backend/Core/Musify.Domain/Entities/ListeningHistory.cs @@ -1,29 +1,30 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace Musify.Domain.Entities; - -[Table("ListeningHistory")] -public class ListeningHistory +namespace Musify.Domain.Entities { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); + [Table("ListeningHistory")] + public class ListeningHistory + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - public required long UserId { get; set; } + public required long UserId { get; set; } - public required Guid TrackId { get; set; } + public required Guid TrackId { get; set; } - public DateTime ListenedAt { get; set; } = DateTime.UtcNow; + public DateTime ListenedAt { get; set; } = DateTime.UtcNow; - public double? PlayedSeconds { get; set; } + public double? PlayedSeconds { get; set; } - public DateTime? LastProgressAt { get; set; } + public DateTime? LastProgressAt { get; set; } - public bool IsCounted { get; set; } + public bool IsCounted { get; set; } - [ForeignKey(nameof(UserId))] - public User User { get; set; } = null!; + [ForeignKey(nameof(UserId))] + public User User { get; set; } = null!; - [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } = null!; + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } = null!; + } } diff --git a/backend/Core/Musify.Domain/Entities/Mix.cs b/backend/Core/Musify.Domain/Entities/Mix.cs index 4951abe..18ec1d3 100644 --- a/backend/Core/Musify.Domain/Entities/Mix.cs +++ b/backend/Core/Musify.Domain/Entities/Mix.cs @@ -3,26 +3,27 @@ using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; using Musify.Domain.ValueObjects; -namespace Musify.Domain.Entities; - -[Table("Mixes")] -public class Mix : IAuditable +namespace Musify.Domain.Entities { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); + [Table("Mixes")] + public class Mix : IAuditable + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - public required long UserId { get; set; } + public required long UserId { get; set; } - public MixKind Kind { get; set; } + public MixKind Kind { get; set; } - public int Position { get; set; } + public int Position { get; set; } - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - [ForeignKey(nameof(UserId))] - public User User { get; set; } = null!; + [ForeignKey(nameof(UserId))] + public User User { get; set; } = null!; - public ICollection Items { get; set; } = new List(); + public ICollection Items { get; set; } = new List(); + } } diff --git a/backend/Core/Musify.Domain/Entities/MixItem.cs b/backend/Core/Musify.Domain/Entities/MixItem.cs index 278e2b8..844da15 100644 --- a/backend/Core/Musify.Domain/Entities/MixItem.cs +++ b/backend/Core/Musify.Domain/Entities/MixItem.cs @@ -1,23 +1,24 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace Musify.Domain.Entities; - -[Table("MixItems")] -public class MixItem +namespace Musify.Domain.Entities { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); + [Table("MixItems")] + public class MixItem + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - public required Guid MixId { get; set; } + public required Guid MixId { get; set; } - public int Position { get; set; } + public int Position { get; set; } - public required Guid TrackId { get; set; } + public required Guid TrackId { get; set; } - [ForeignKey(nameof(MixId))] - public Mix Mix { get; set; } = null!; + [ForeignKey(nameof(MixId))] + public Mix Mix { get; set; } = null!; - [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } = null!; + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } = null!; + } } diff --git a/backend/Core/Musify.Domain/Entities/PlayList.cs b/backend/Core/Musify.Domain/Entities/PlayList.cs index 7774958..5a31a9e 100644 --- a/backend/Core/Musify.Domain/Entities/PlayList.cs +++ b/backend/Core/Musify.Domain/Entities/PlayList.cs @@ -4,38 +4,39 @@ using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; using Musify.Domain.ValueObjects; -namespace Musify.Domain.Entities; - -[Table("PlayLists")] -public class PlayList : IAuditable, IHasLifeCycle, IOwnedEntity +namespace Musify.Domain.Entities { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); + [Table("PlayLists")] + public class PlayList : IAuditable, IHasLifeCycle, IOwnedEntity + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - [Column("UserId")] - public required long OwnerUserId { get; set; } + [Column("UserId")] + public required long OwnerUserId { get; set; } - [MaxLength(50)] - public required string Name { get; set; } + [MaxLength(50)] + public required string Name { get; set; } - [MaxLength(50)] - public required string NormalizedName { get; set; } + [MaxLength(50)] + public required string NormalizedName { get; set; } - [MaxLength(256)] - public string? Description { get; set; } + [MaxLength(256)] + public string? Description { get; set; } - public EntityPictures? Pictures { get; set; } + public EntityPictures? Pictures { get; set; } - public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; + public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; - public PlayListVisibility Visibility { get; set; } = PlayListVisibility.Private; + public PlayListVisibility Visibility { get; set; } = PlayListVisibility.Private; - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - [ForeignKey(nameof(OwnerUserId))] - public User Owner { get; set; } = null!; + [ForeignKey(nameof(OwnerUserId))] + public User Owner { get; set; } = null!; - public ICollection PlayListTracks { get; set; } = new List(); + public ICollection PlayListTracks { get; set; } = new List(); + } } diff --git a/backend/Core/Musify.Domain/Entities/PlayListHasTrack.cs b/backend/Core/Musify.Domain/Entities/PlayListHasTrack.cs index 173bf5b..a8e2c33 100644 --- a/backend/Core/Musify.Domain/Entities/PlayListHasTrack.cs +++ b/backend/Core/Musify.Domain/Entities/PlayListHasTrack.cs @@ -1,23 +1,24 @@ using System.ComponentModel.DataAnnotations.Schema; -namespace Musify.Domain.Entities; - -[Table("PlayListHasTrack")] -public class PlayListHasTrack +namespace Musify.Domain.Entities { - public Guid Id { get; set; } = Guid.NewGuid(); + [Table("PlayListHasTrack")] + public class PlayListHasTrack + { + public Guid Id { get; set; } = Guid.NewGuid(); - public required Guid PlayListId { get; set; } + public required Guid PlayListId { get; set; } - public required Guid TrackId { get; set; } + public required Guid TrackId { get; set; } - public int Position { get; set; } + public int Position { get; set; } - public DateTime AddedAt { get; set; } = DateTime.UtcNow; + public DateTime AddedAt { get; set; } = DateTime.UtcNow; - [ForeignKey(nameof(PlayListId))] - public PlayList PlayList { get; set; } = null!; + [ForeignKey(nameof(PlayListId))] + public PlayList PlayList { get; set; } = null!; - [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } = null!; + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } = null!; + } } diff --git a/backend/Core/Musify.Domain/Entities/Track.cs b/backend/Core/Musify.Domain/Entities/Track.cs index 04da190..e2a7c85 100644 --- a/backend/Core/Musify.Domain/Entities/Track.cs +++ b/backend/Core/Musify.Domain/Entities/Track.cs @@ -3,44 +3,45 @@ using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; using Musify.Domain.ValueObjects; -namespace Musify.Domain.Entities; - -[Table("Tracks")] -public class Track : IAuditable, IHasLifeCycle, IOwnedEntity +namespace Musify.Domain.Entities { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); + [Table("Tracks")] + public class Track : IAuditable, IHasLifeCycle, IOwnedEntity + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - [MaxLength(200)] - public required string Title { get; set; } + [MaxLength(200)] + public required string Title { get; set; } - [MaxLength(200)] - public required string NormalizedTitle { get; set; } + [MaxLength(200)] + public required string NormalizedTitle { get; set; } - public double DurationSeconds { get; set; } + public double DurationSeconds { get; set; } - public required long OwnerUserId { get; set; } + public required long OwnerUserId { get; set; } - [ForeignKey(nameof(OwnerUserId))] - public User Owner { get; set; } = null!; + [ForeignKey(nameof(OwnerUserId))] + public User Owner { get; set; } = null!; - public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; + public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; - public bool IsExplicit { get; set; } + public bool IsExplicit { get; set; } - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - public TrackPictures Pictures { get; set; } = new(); + public TrackPictures Pictures { get; set; } = new(); - public TrackAudio Audio { get; set; } = new(); + public TrackAudio Audio { get; set; } = new(); - public ICollection PlayListTracks { get; set; } = new List(); + public ICollection PlayListTracks { get; set; } = new List(); - public ICollection AlbumTracks { get; set; } = new List(); + public ICollection AlbumTracks { get; set; } = new List(); - public ICollection ListeningHistories { get; set; } = new List(); + public ICollection ListeningHistories { get; set; } = new List(); - public ICollection Tags { get; set; } = new List(); + public ICollection Tags { get; set; } = new List(); + } } diff --git a/backend/Core/Musify.Domain/Entities/TrackLike.cs b/backend/Core/Musify.Domain/Entities/TrackLike.cs index 9cf41d3..2e10ce4 100644 --- a/backend/Core/Musify.Domain/Entities/TrackLike.cs +++ b/backend/Core/Musify.Domain/Entities/TrackLike.cs @@ -1,23 +1,24 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace Musify.Domain.Entities; - -[Table("TrackLikes")] -public class TrackLike +namespace Musify.Domain.Entities { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); + [Table("TrackLikes")] + public class TrackLike + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - public required long UserId { get; set; } + public required long UserId { get; set; } - public required Guid TrackId { get; set; } + public required Guid TrackId { get; set; } - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - [ForeignKey(nameof(UserId))] - public User User { get; set; } = null!; + [ForeignKey(nameof(UserId))] + public User User { get; set; } = null!; - [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } = null!; + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } = null!; + } } diff --git a/backend/Core/Musify.Domain/Entities/TrackTag.cs b/backend/Core/Musify.Domain/Entities/TrackTag.cs index 0f599a4..4bf2389 100644 --- a/backend/Core/Musify.Domain/Entities/TrackTag.cs +++ b/backend/Core/Musify.Domain/Entities/TrackTag.cs @@ -2,18 +2,19 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.ValueObjects; -namespace Musify.Domain.Entities; - -[Table("TrackTags")] -public class TrackTag +namespace Musify.Domain.Entities { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); + [Table("TrackTags")] + public class TrackTag + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - public required Guid TrackId { get; set; } + public required Guid TrackId { get; set; } - public required Genre Tag { get; set; } + public required Genre Tag { get; set; } - [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } = null!; + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } = null!; + } } diff --git a/backend/Core/Musify.Domain/Entities/UploadIntent.cs b/backend/Core/Musify.Domain/Entities/UploadIntent.cs index f3fc802..176f8f4 100644 --- a/backend/Core/Musify.Domain/Entities/UploadIntent.cs +++ b/backend/Core/Musify.Domain/Entities/UploadIntent.cs @@ -3,43 +3,44 @@ using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.ValueObjects; -namespace Musify.Domain.Entities; - -[Table("UploadIntents")] -public class UploadIntent +namespace Musify.Domain.Entities { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); + [Table("UploadIntents")] + public class UploadIntent + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - public required long UserId { get; set; } + public required long UserId { get; set; } - [MaxLength(128)] - public required string Bucket { get; set; } + [MaxLength(128)] + public required string Bucket { get; set; } - [MaxLength(512)] - public required string Key { get; set; } + [MaxLength(512)] + public required string Key { get; set; } - [MaxLength(128)] - public required string ObjectName { get; set; } + [MaxLength(128)] + public required string ObjectName { get; set; } - [MaxLength(128)] - public required string ContentType { get; set; } + [MaxLength(128)] + public required string ContentType { get; set; } - public long? ExpectedSizeBytes { get; set; } + public long? ExpectedSizeBytes { get; set; } - public UploadIntentPurpose Purpose { get; set; } + public UploadIntentPurpose Purpose { get; set; } - public UploadIntentStatus Status { get; set; } = UploadIntentStatus.Issued; + public UploadIntentStatus Status { get; set; } = UploadIntentStatus.Issued; - public DateTime ExpiresAt { get; set; } + public DateTime ExpiresAt { get; set; } - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - [NotMapped] - public bool IsConsumed => Status == UploadIntentStatus.Consumed; + [NotMapped] + public bool IsConsumed => Status == UploadIntentStatus.Consumed; - public bool IsExpiredAt(DateTime now) => Status == UploadIntentStatus.Expired || ExpiresAt < now; + public bool IsExpiredAt(DateTime now) => Status == UploadIntentStatus.Expired || ExpiresAt < now; - [ForeignKey(nameof(UserId))] - public User User { get; set; } = null!; + [ForeignKey(nameof(UserId))] + public User User { get; set; } = null!; + } } diff --git a/backend/Core/Musify.Domain/Entities/User.cs b/backend/Core/Musify.Domain/Entities/User.cs index 7616a1e..078c99b 100644 --- a/backend/Core/Musify.Domain/Entities/User.cs +++ b/backend/Core/Musify.Domain/Entities/User.cs @@ -2,35 +2,36 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; -namespace Musify.Domain.Entities; - -[Table("User")] -public class User : IAuditable +namespace Musify.Domain.Entities { - [Key] - [DatabaseGenerated(DatabaseGeneratedOption.None)] - public required long Id { get; set; } + [Table("User")] + public class User : IAuditable + { + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.None)] + public required long Id { get; set; } - [MaxLength(48)] - public required string Name { get; set; } + [MaxLength(48)] + public required string Name { get; set; } - [MaxLength(48)] - public required string NormalizedName { get; set; } + [MaxLength(48)] + public required string NormalizedName { get; set; } - [MaxLength(48)] - public string? FirstName { get; set; } + [MaxLength(48)] + public string? FirstName { get; set; } - [MaxLength(48)] - public string? SecondName { get; set; } + [MaxLength(48)] + public string? SecondName { get; set; } - [MaxLength(2048)] - public string? ProfilePictureUrl { get; set; } + [MaxLength(2048)] + public string? ProfilePictureUrl { get; set; } - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - public ICollection PlayLists { get; set; } = new List(); + public ICollection PlayLists { get; set; } = new List(); - public ICollection ListeningHistories { get; set; } = new List(); + public ICollection ListeningHistories { get; set; } = new List(); + } } diff --git a/backend/Core/Musify.Domain/Entities/UserFollow.cs b/backend/Core/Musify.Domain/Entities/UserFollow.cs index 93b324c..01a9b77 100644 --- a/backend/Core/Musify.Domain/Entities/UserFollow.cs +++ b/backend/Core/Musify.Domain/Entities/UserFollow.cs @@ -1,23 +1,24 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace Musify.Domain.Entities; - -[Table("UserFollows")] -public class UserFollow +namespace Musify.Domain.Entities { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); + [Table("UserFollows")] + public class UserFollow + { + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - public required long FollowerId { get; set; } + public required long FollowerId { get; set; } - public required long FollowedId { get; set; } + public required long FollowedId { get; set; } - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - [ForeignKey(nameof(FollowerId))] - public User Follower { get; set; } = null!; + [ForeignKey(nameof(FollowerId))] + public User Follower { get; set; } = null!; - [ForeignKey(nameof(FollowedId))] - public User Followed { get; set; } = null!; + [ForeignKey(nameof(FollowedId))] + public User Followed { get; set; } = null!; + } } diff --git a/backend/Core/Musify.Domain/ValueObjects/EntityPictures.cs b/backend/Core/Musify.Domain/ValueObjects/EntityPictures.cs index 9cc8db5..bc6e091 100644 --- a/backend/Core/Musify.Domain/ValueObjects/EntityPictures.cs +++ b/backend/Core/Musify.Domain/ValueObjects/EntityPictures.cs @@ -1,18 +1,19 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Domain.ValueObjects; - -/// The picture file names of an album or playlist. Resized names stay null until processing finishes. -public class EntityPictures +namespace Musify.Domain.ValueObjects { - [Required] - public required string OriginalName { get; set; } + /// The picture file names of an album or playlist. Resized names stay null until processing finishes. + public class EntityPictures + { + [Required] + public required string OriginalName { get; set; } - public string? SmallName { get; set; } + public string? SmallName { get; set; } - public string? MediumName { get; set; } + public string? MediumName { get; set; } - public string? LargeName { get; set; } + public string? LargeName { get; set; } - public static EntityPictures Pending(string objectName) => new() { OriginalName = objectName }; + public static EntityPictures Pending(string objectName) => new() { OriginalName = objectName }; + } } diff --git a/backend/Core/Musify.Domain/ValueObjects/Genre.cs b/backend/Core/Musify.Domain/ValueObjects/Genre.cs index d3db9f1..dc9eeb9 100644 --- a/backend/Core/Musify.Domain/ValueObjects/Genre.cs +++ b/backend/Core/Musify.Domain/ValueObjects/Genre.cs @@ -1,31 +1,32 @@ -namespace Musify.Domain.ValueObjects; - -public enum Genre +namespace Musify.Domain.ValueObjects { - Pop = 0, - Rock = 1, - HipHop = 2, - RnB = 3, - Jazz = 4, - Blues = 5, - Classical = 6, - Electronic = 7, - House = 8, - Techno = 9, - Trance = 10, - Dubstep = 11, - DrumAndBass = 12, - Metal = 13, - Punk = 14, - Reggae = 15, - Reggaeton = 16, - Country = 17, - Folk = 18, - Indie = 19, - KPop = 20, - Latin = 21, - Soul = 22, - Funk = 23, - Ambient = 24, - Lofi = 25 + public enum Genre + { + Pop = 0, + Rock = 1, + HipHop = 2, + RnB = 3, + Jazz = 4, + Blues = 5, + Classical = 6, + Electronic = 7, + House = 8, + Techno = 9, + Trance = 10, + Dubstep = 11, + DrumAndBass = 12, + Metal = 13, + Punk = 14, + Reggae = 15, + Reggaeton = 16, + Country = 17, + Folk = 18, + Indie = 19, + KPop = 20, + Latin = 21, + Soul = 22, + Funk = 23, + Ambient = 24, + Lofi = 25 + } } diff --git a/backend/Core/Musify.Domain/ValueObjects/GenreCompatibility.cs b/backend/Core/Musify.Domain/ValueObjects/GenreCompatibility.cs index 4cd1733..bd753f3 100644 --- a/backend/Core/Musify.Domain/ValueObjects/GenreCompatibility.cs +++ b/backend/Core/Musify.Domain/ValueObjects/GenreCompatibility.cs @@ -1,49 +1,50 @@ -namespace Musify.Domain.ValueObjects; - - -public static class GenreCompatibility +namespace Musify.Domain.ValueObjects { - private static readonly HashSet<(Genre, Genre)> IncompatiblePairs = BuildIncompatiblePairs(); - private static HashSet<(Genre, Genre)> BuildIncompatiblePairs() + public static class GenreCompatibility { - var pairs = new HashSet<(Genre, Genre)>(); + private static readonly HashSet<(Genre, Genre)> IncompatiblePairs = BuildIncompatiblePairs(); - void Add(Genre a, Genre b) + private static HashSet<(Genre, Genre)> BuildIncompatiblePairs() { - pairs.Add((a, b)); - pairs.Add((b, a)); - } + var pairs = new HashSet<(Genre, Genre)>(); - var intense = new[] { Genre.Metal, Genre.Punk, Genre.Dubstep, Genre.DrumAndBass }; - var mellow = new[] { Genre.Classical, Genre.Ambient, Genre.Lofi }; - - foreach (var intenseGenre in intense) - { - foreach (var mellowGenre in mellow) - Add(intenseGenre, mellowGenre); - } - - return pairs; - } - - public static bool AreCompatible(Genre a, Genre b) => - a == b || !IncompatiblePairs.Contains((a, b)); - - public static IReadOnlyCollection<(Genre First, Genre Second)> FindConflicts(IEnumerable tags) - { - var distinctTags = tags.Distinct().ToList(); - var conflicts = new List<(Genre, Genre)>(); - - for (var i = 0; i < distinctTags.Count; i++) - { - for (var j = i + 1; j < distinctTags.Count; j++) + void Add(Genre a, Genre b) { - if (!AreCompatible(distinctTags[i], distinctTags[j])) - conflicts.Add((distinctTags[i], distinctTags[j])); + pairs.Add((a, b)); + pairs.Add((b, a)); } + + var intense = new[] { Genre.Metal, Genre.Punk, Genre.Dubstep, Genre.DrumAndBass }; + var mellow = new[] { Genre.Classical, Genre.Ambient, Genre.Lofi }; + + foreach (var intenseGenre in intense) + { + foreach (var mellowGenre in mellow) + Add(intenseGenre, mellowGenre); + } + + return pairs; } - return conflicts; + public static bool AreCompatible(Genre a, Genre b) => + a == b || !IncompatiblePairs.Contains((a, b)); + + public static IReadOnlyCollection<(Genre First, Genre Second)> FindConflicts(IEnumerable tags) + { + var distinctTags = tags.Distinct().ToList(); + var conflicts = new List<(Genre, Genre)>(); + + for (var i = 0; i < distinctTags.Count; i++) + { + for (var j = i + 1; j < distinctTags.Count; j++) + { + if (!AreCompatible(distinctTags[i], distinctTags[j])) + conflicts.Add((distinctTags[i], distinctTags[j])); + } + } + + return conflicts; + } } } diff --git a/backend/Core/Musify.Domain/ValueObjects/LifeCycleStatus.cs b/backend/Core/Musify.Domain/ValueObjects/LifeCycleStatus.cs index 04f9e20..4e21417 100644 --- a/backend/Core/Musify.Domain/ValueObjects/LifeCycleStatus.cs +++ b/backend/Core/Musify.Domain/ValueObjects/LifeCycleStatus.cs @@ -1,8 +1,9 @@ -namespace Musify.Domain.ValueObjects; - -public enum LifeCycleStatus +namespace Musify.Domain.ValueObjects { - Active = 0, - Removing = 1, - Failed = 2 + public enum LifeCycleStatus + { + Active = 0, + Removing = 1, + Failed = 2 + } } diff --git a/backend/Core/Musify.Domain/ValueObjects/MixKind.cs b/backend/Core/Musify.Domain/ValueObjects/MixKind.cs index 2e3fb69..3a0576f 100644 --- a/backend/Core/Musify.Domain/ValueObjects/MixKind.cs +++ b/backend/Core/Musify.Domain/ValueObjects/MixKind.cs @@ -1,7 +1,8 @@ -namespace Musify.Domain.ValueObjects; - -public enum MixKind +namespace Musify.Domain.ValueObjects { - Discovery = 0, - Daily = 1 + public enum MixKind + { + Discovery = 0, + Daily = 1 + } } diff --git a/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs b/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs index b6574d8..8ea1473 100644 --- a/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs +++ b/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs @@ -1,7 +1,8 @@ -namespace Musify.Domain.ValueObjects; - -public enum PlayListVisibility +namespace Musify.Domain.ValueObjects { - Private = 0, - Public = 1 + public enum PlayListVisibility + { + Private = 0, + Public = 1 + } } diff --git a/backend/Core/Musify.Domain/ValueObjects/ProcessingStatus.cs b/backend/Core/Musify.Domain/ValueObjects/ProcessingStatus.cs index d2f681e..7a9fdc2 100644 --- a/backend/Core/Musify.Domain/ValueObjects/ProcessingStatus.cs +++ b/backend/Core/Musify.Domain/ValueObjects/ProcessingStatus.cs @@ -1,9 +1,10 @@ -namespace Musify.Domain.ValueObjects; - -public enum ProcessingStatus +namespace Musify.Domain.ValueObjects { - Pending = 0, - Processing = 1, - Completed = 2, - Failed = 3 + public enum ProcessingStatus + { + Pending = 0, + Processing = 1, + Completed = 2, + Failed = 3 + } } diff --git a/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs b/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs index 8ed8584..c61ae42 100644 --- a/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs +++ b/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs @@ -1,19 +1,20 @@ using System.Diagnostics.CodeAnalysis; -namespace Musify.Domain.ValueObjects; - -public class TrackAudio +namespace Musify.Domain.ValueObjects { - public string? OriginalName { get; set; } + public class TrackAudio + { + public string? OriginalName { get; set; } - public string? FolderName { get; set; } + public string? FolderName { get; set; } - public ProcessingStatus TranscodeStatus { get; set; } = ProcessingStatus.Pending; + public ProcessingStatus TranscodeStatus { get; set; } = ProcessingStatus.Pending; - [MemberNotNullWhen(true, nameof(FolderName))] - public bool IsProcessed => TranscodeStatus == ProcessingStatus.Completed; + [MemberNotNullWhen(true, nameof(FolderName))] + public bool IsProcessed => TranscodeStatus == ProcessingStatus.Completed; - public bool IsFailed => TranscodeStatus == ProcessingStatus.Failed; + public bool IsFailed => TranscodeStatus == ProcessingStatus.Failed; - public bool IsInProgress => TranscodeStatus is ProcessingStatus.Pending or ProcessingStatus.Processing; + public bool IsInProgress => TranscodeStatus is ProcessingStatus.Pending or ProcessingStatus.Processing; + } } diff --git a/backend/Core/Musify.Domain/ValueObjects/TrackPictures.cs b/backend/Core/Musify.Domain/ValueObjects/TrackPictures.cs index 05a57d8..9cf5243 100644 --- a/backend/Core/Musify.Domain/ValueObjects/TrackPictures.cs +++ b/backend/Core/Musify.Domain/ValueObjects/TrackPictures.cs @@ -1,21 +1,22 @@ using System.Diagnostics.CodeAnalysis; -namespace Musify.Domain.ValueObjects; - -public class TrackPictures +namespace Musify.Domain.ValueObjects { - public string? OriginalName { get; set; } + public class TrackPictures + { + public string? OriginalName { get; set; } - public string? SmallName { get; set; } + public string? SmallName { get; set; } - public string? MediumName { get; set; } + public string? MediumName { get; set; } - public string? LargeName { get; set; } + public string? LargeName { get; set; } - public ProcessingStatus ProcessingStatus { get; set; } = ProcessingStatus.Pending; + public ProcessingStatus ProcessingStatus { get; set; } = ProcessingStatus.Pending; - [MemberNotNullWhen(true, nameof(SmallName))] - [MemberNotNullWhen(true, nameof(MediumName))] - [MemberNotNullWhen(true, nameof(LargeName))] - public bool IsProcessed => ProcessingStatus == ProcessingStatus.Completed; + [MemberNotNullWhen(true, nameof(SmallName))] + [MemberNotNullWhen(true, nameof(MediumName))] + [MemberNotNullWhen(true, nameof(LargeName))] + public bool IsProcessed => ProcessingStatus == ProcessingStatus.Completed; + } } diff --git a/backend/Core/Musify.Domain/ValueObjects/UploadIntentPurpose.cs b/backend/Core/Musify.Domain/ValueObjects/UploadIntentPurpose.cs index 9d98403..9629594 100644 --- a/backend/Core/Musify.Domain/ValueObjects/UploadIntentPurpose.cs +++ b/backend/Core/Musify.Domain/ValueObjects/UploadIntentPurpose.cs @@ -1,9 +1,10 @@ -namespace Musify.Domain.ValueObjects; - -public enum UploadIntentPurpose +namespace Musify.Domain.ValueObjects { - PlayListPicture, - TrackPicture, - TrackAudio, - AlbumPicture, + public enum UploadIntentPurpose + { + PlayListPicture, + TrackPicture, + TrackAudio, + AlbumPicture, + } } diff --git a/backend/Core/Musify.Domain/ValueObjects/UploadIntentStatus.cs b/backend/Core/Musify.Domain/ValueObjects/UploadIntentStatus.cs index 85f3934..7a6e23f 100644 --- a/backend/Core/Musify.Domain/ValueObjects/UploadIntentStatus.cs +++ b/backend/Core/Musify.Domain/ValueObjects/UploadIntentStatus.cs @@ -1,8 +1,9 @@ -namespace Musify.Domain.ValueObjects; - -public enum UploadIntentStatus +namespace Musify.Domain.ValueObjects { - Issued, - Consumed, - Expired, + public enum UploadIntentStatus + { + Issued, + Consumed, + Expired, + } } diff --git a/backend/Core/Musify.Infrastructure/Configuration/AudioConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/AudioConfiguration.cs index e2abd10..87af825 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/AudioConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/AudioConfiguration.cs @@ -1,41 +1,42 @@ using System.ComponentModel.DataAnnotations; using Musify.Application.Configuration; -namespace Musify.Infrastructure.Configuration; - -public class AudioConfiguration : IConfigurationOptions +namespace Musify.Infrastructure.Configuration { - public static string SectionName => "AudioTranscoder"; + public class AudioConfiguration : IConfigurationOptions + { + public static string SectionName => "AudioTranscoder"; - [Required] - public TimeSpan TranscodingTimeout { get; set; } = TimeSpan.FromMinutes(5); + [Required] + public TimeSpan TranscodingTimeout { get; set; } = TimeSpan.FromMinutes(5); - [Required] - public FfmpegConfiguration Ffmpeg { get; set; } = new FfmpegConfiguration(); -} + [Required] + public FfmpegConfiguration Ffmpeg { get; set; } = new FfmpegConfiguration(); + } -public class FfmpegConfiguration -{ - [Required] - public string ExecutableName { get; set; } = "ffmpeg"; + public class FfmpegConfiguration + { + [Required] + public string ExecutableName { get; set; } = "ffmpeg"; - [Required] - public string OutputFileName { get; set; } = "audio.m4a"; + [Required] + public string OutputFileName { get; set; } = "audio.m4a"; - [Required] - public string AudioCodec { get; set; } = "aac"; + [Required] + public string AudioCodec { get; set; } = "aac"; - [Required] - public string AudioBitrate { get; set; } = "128k"; + [Required] + public string AudioBitrate { get; set; } = "128k"; - [Range(1, 8)] - public int AudioChannels { get; set; } = 2; + [Range(1, 8)] + public int AudioChannels { get; set; } = 2; - [Range(8000, 192000)] - public int AudioSampleRate { get; set; } = 48000; + [Range(8000, 192000)] + public int AudioSampleRate { get; set; } = 48000; - [Required] - public string AudioProfile { get; set; } = "aac_low"; + [Required] + public string AudioProfile { get; set; } = "aac_low"; - public string AdditionalArguments { get; set; } = string.Empty; + public string AdditionalArguments { get; set; } = string.Empty; + } } diff --git a/backend/Core/Musify.Infrastructure/Configuration/DatabaseConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/DatabaseConfiguration.cs index 6df8c20..d0549ff 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/DatabaseConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/DatabaseConfiguration.cs @@ -1,12 +1,13 @@ using System.ComponentModel.DataAnnotations; using Musify.Application.Configuration; -namespace Musify.Infrastructure.Configuration; - -public class DatabaseConfiguration : IConfigurationOptions +namespace Musify.Infrastructure.Configuration { - public static string SectionName => "Database"; + public class DatabaseConfiguration : IConfigurationOptions + { + public static string SectionName => "Database"; - [Required] - public required string ConnectionString { get; set; } + [Required] + public required string ConnectionString { get; set; } + } } diff --git a/backend/Core/Musify.Infrastructure/Configuration/InfrastructureStorageConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/InfrastructureStorageConfiguration.cs index 45738bf..6f7a6d7 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/InfrastructureStorageConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/InfrastructureStorageConfiguration.cs @@ -1,25 +1,26 @@ using System.ComponentModel.DataAnnotations; using Musify.Application.Configuration; -namespace Musify.Infrastructure.Configuration; - -public class InfrastructureStorageConfiguration : IConfigurationOptions +namespace Musify.Infrastructure.Configuration { - public static string SectionName => "InfrastructureStorage"; + public class InfrastructureStorageConfiguration : IConfigurationOptions + { + public static string SectionName => "InfrastructureStorage"; - [Url] - [Required] - public required string Address { get; set; } + [Url] + [Required] + public required string Address { get; set; } - [Required] - public required string AccessKey { get; set; } + [Required] + public required string AccessKey { get; set; } - [Required] - public required string SecretAccessKey { get; set; } + [Required] + public required string SecretAccessKey { get; set; } - [Required] - public bool ForcePathStyle { get; set; } + [Required] + public bool ForcePathStyle { get; set; } - [Required] - public bool UseHttp { get; set; } + [Required] + public bool UseHttp { get; set; } + } } diff --git a/backend/Core/Musify.Infrastructure/Configuration/MassTransitConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/MassTransitConfiguration.cs index 8472cbd..d64a470 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/MassTransitConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/MassTransitConfiguration.cs @@ -1,18 +1,19 @@ using System.ComponentModel.DataAnnotations; using Musify.Application.Configuration; -namespace Musify.Infrastructure.Configuration; - -public class MassTransitConfiguration : IConfigurationOptions +namespace Musify.Infrastructure.Configuration { - public static string SectionName => "MassTransit"; + public class MassTransitConfiguration : IConfigurationOptions + { + public static string SectionName => "MassTransit"; - [Required] - public required string Address { get; set; } + [Required] + public required string Address { get; set; } - [Required] - public required string Username { get; set; } + [Required] + public required string Username { get; set; } - [Required] - public required string Password { get; set; } + [Required] + public required string Password { get; set; } + } } diff --git a/backend/Core/Musify.Infrastructure/Configuration/OpenTelemetryConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/OpenTelemetryConfiguration.cs index e12adcd..3f8e02e 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/OpenTelemetryConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/OpenTelemetryConfiguration.cs @@ -1,13 +1,14 @@ using System.ComponentModel.DataAnnotations; using Musify.Application.Configuration; -namespace Musify.Infrastructure.Configuration; - -public class OpenTelemetryConfiguration : IConfigurationOptions +namespace Musify.Infrastructure.Configuration { - public static string SectionName => "OpenTelemetry"; + public class OpenTelemetryConfiguration : IConfigurationOptions + { + public static string SectionName => "OpenTelemetry"; - [Url] - [Required] - public required string OtlpEndpoint { get; set; } + [Url] + [Required] + public required string OtlpEndpoint { get; set; } + } } diff --git a/backend/Core/Musify.Infrastructure/Configuration/StreamTicketConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/StreamTicketConfiguration.cs index 26c9130..18120a8 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/StreamTicketConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/StreamTicketConfiguration.cs @@ -1,21 +1,22 @@ using System.ComponentModel.DataAnnotations; using Musify.Application.Configuration; -namespace Musify.Infrastructure.Configuration; - -public class StreamTicketConfiguration : IConfigurationOptions +namespace Musify.Infrastructure.Configuration { - public static string SectionName => "StreamTicket"; + public class StreamTicketConfiguration : IConfigurationOptions + { + public static string SectionName => "StreamTicket"; - [Required] - public required string PrivateKeyPath { get; set; } + [Required] + public required string PrivateKeyPath { get; set; } - [Required] - public string Audience { get; set; } = "media-gateway"; + [Required] + public string Audience { get; set; } = "media-gateway"; - [Required] - public string Issuer { get; set; } = "musify-webapi"; + [Required] + public string Issuer { get; set; } = "musify-webapi"; - [Range(60, 86400)] - public int TicketTtlSeconds { get; set; } = 3600; + [Range(60, 86400)] + public int TicketTtlSeconds { get; set; } = 3600; + } } diff --git a/backend/Core/Musify.Infrastructure/Configuration/WorkerConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/WorkerConfiguration.cs index 3af6ef4..d7309b9 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/WorkerConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/WorkerConfiguration.cs @@ -1,18 +1,19 @@ using System.ComponentModel.DataAnnotations; using Musify.Application.Configuration; -namespace Musify.Infrastructure.Configuration; - -public class WorkerConfiguration : IConfigurationOptions +namespace Musify.Infrastructure.Configuration { - public static string SectionName => "Workers"; + public class WorkerConfiguration : IConfigurationOptions + { + public static string SectionName => "Workers"; - [Required] - public WorkerRoutes Routes { get; set; } = new WorkerRoutes(); -} + [Required] + public WorkerRoutes Routes { get; set; } = new WorkerRoutes(); + } -public class WorkerRoutes -{ - [Required] - public string TemporaryFilesDirectory { get; set; } = Path.GetTempPath(); + public class WorkerRoutes + { + [Required] + public string TemporaryFilesDirectory { get; set; } = Path.GetTempPath(); + } } diff --git a/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs b/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs index d610c1e..5ff17e7 100644 --- a/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs +++ b/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs @@ -4,27 +4,28 @@ using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Application.Mixes; -namespace Musify.Infrastructure.Jobs; - -public class DailyMixGenerationJob( - IDatabase database, - IMediator mediator, - ILogger logger) +namespace Musify.Infrastructure.Jobs { - public async Task RunAsync(CancellationToken cancellationToken) + public class DailyMixGenerationJob( + IDatabase database, + IMediator mediator, + ILogger logger) { - var userIds = await database.ListeningHistories - .AsNoTracking() - .Where(history => history.IsCounted) - .Select(history => history.UserId) - .Distinct() - .ToListAsync(cancellationToken); + public async Task RunAsync(CancellationToken cancellationToken) + { + var userIds = await database.ListeningHistories + .AsNoTracking() + .Where(history => history.IsCounted) + .Select(history => history.UserId) + .Distinct() + .ToListAsync(cancellationToken); - logger.LogInformation("Starting daily mix generation for {Count} users", userIds.Count); + logger.LogInformation("Starting daily mix generation for {Count} users", userIds.Count); - foreach (var userId in userIds) - await mediator.Send(new GenerateMixesForUserCommand(userId), cancellationToken); + foreach (var userId in userIds) + await mediator.Send(new GenerateMixesForUserCommand(userId), cancellationToken); - logger.LogInformation("Finished daily mix generation"); + logger.LogInformation("Finished daily mix generation"); + } } } diff --git a/backend/Core/Musify.Infrastructure/Jobs/ListeningHistoryCleanupJob.cs b/backend/Core/Musify.Infrastructure/Jobs/ListeningHistoryCleanupJob.cs index 9a91b3c..fc8e1b3 100644 --- a/backend/Core/Musify.Infrastructure/Jobs/ListeningHistoryCleanupJob.cs +++ b/backend/Core/Musify.Infrastructure/Jobs/ListeningHistoryCleanupJob.cs @@ -1,12 +1,13 @@ using Mediator; using Musify.Application.Tracks; -namespace Musify.Infrastructure.Jobs; - -public class ListeningHistoryCleanupJob(IMediator mediator) +namespace Musify.Infrastructure.Jobs { - public async Task RunAsync(CancellationToken cancellationToken) + public class ListeningHistoryCleanupJob(IMediator mediator) { - await mediator.Send(new DeleteStaleUncountedListensCommand(), cancellationToken); + public async Task RunAsync(CancellationToken cancellationToken) + { + await mediator.Send(new DeleteStaleUncountedListensCommand(), cancellationToken); + } } } diff --git a/backend/Core/Musify.Infrastructure/Jobs/RecurringJobsRegistrar.cs b/backend/Core/Musify.Infrastructure/Jobs/RecurringJobsRegistrar.cs index ed278f6..a2f154b 100644 --- a/backend/Core/Musify.Infrastructure/Jobs/RecurringJobsRegistrar.cs +++ b/backend/Core/Musify.Infrastructure/Jobs/RecurringJobsRegistrar.cs @@ -2,40 +2,41 @@ using Hangfire; using Microsoft.Extensions.Hosting; using Musify.Application.Configuration; -namespace Musify.Infrastructure.Jobs; - -/// Registers every recurring Hangfire job when the host starts. -public sealed class RecurringJobsRegistrar( - IRecurringJobManager recurringJobManager, - UploadIntentConfiguration uploadIntentConfiguration) - : IHostedService +namespace Musify.Infrastructure.Jobs { - public Task StartAsync(CancellationToken cancellationToken) + /// Registers every recurring Hangfire job when the host starts. + public sealed class RecurringJobsRegistrar( + IRecurringJobManager recurringJobManager, + UploadIntentConfiguration uploadIntentConfiguration) + : IHostedService { - var options = new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }; + public Task StartAsync(CancellationToken cancellationToken) + { + var options = new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }; - recurringJobManager.AddOrUpdate( - "daily-mix-generation", job => job.RunAsync(CancellationToken.None), Cron.Daily(), options); - recurringJobManager.AddOrUpdate( - "listening-history-cleanup", job => job.RunAsync(CancellationToken.None), Cron.Daily(3), options); - recurringJobManager.AddOrUpdate( - "upload-intent-expiration", job => job.RunAsync(CancellationToken.None), - EveryCron(uploadIntentConfiguration.ExpirationJobIntervalSeconds), options); - recurringJobManager.AddOrUpdate( - "temp-uploads-cleanup", job => job.RunAsync(CancellationToken.None), - EveryCron(uploadIntentConfiguration.TempCleanupJobIntervalSeconds), options); + recurringJobManager.AddOrUpdate( + "daily-mix-generation", job => job.RunAsync(CancellationToken.None), Cron.Daily(), options); + recurringJobManager.AddOrUpdate( + "listening-history-cleanup", job => job.RunAsync(CancellationToken.None), Cron.Daily(3), options); + recurringJobManager.AddOrUpdate( + "upload-intent-expiration", job => job.RunAsync(CancellationToken.None), + EveryCron(uploadIntentConfiguration.ExpirationJobIntervalSeconds), options); + recurringJobManager.AddOrUpdate( + "temp-uploads-cleanup", job => job.RunAsync(CancellationToken.None), + EveryCron(uploadIntentConfiguration.TempCleanupJobIntervalSeconds), options); - return Task.CompletedTask; - } + return Task.CompletedTask; + } - public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; - /// Hangfire cron has a one-minute resolution, so the interval is rounded up to whole minutes. - internal static string EveryCron(int intervalSeconds) - { - var minutes = Math.Max(1, (int)Math.Ceiling(intervalSeconds / 60.0)); - return minutes < 60 - ? $"*/{minutes} * * * *" - : $"0 */{Math.Max(1, minutes / 60)} * * *"; + /// Hangfire cron has a one-minute resolution, so the interval is rounded up to whole minutes. + internal static string EveryCron(int intervalSeconds) + { + var minutes = Math.Max(1, (int)Math.Ceiling(intervalSeconds / 60.0)); + return minutes < 60 + ? $"*/{minutes} * * * *" + : $"0 */{Math.Max(1, minutes / 60)} * * *"; + } } } diff --git a/backend/Core/Musify.Infrastructure/Jobs/TempUploadsCleanupJob.cs b/backend/Core/Musify.Infrastructure/Jobs/TempUploadsCleanupJob.cs index 5295904..a8aecff 100644 --- a/backend/Core/Musify.Infrastructure/Jobs/TempUploadsCleanupJob.cs +++ b/backend/Core/Musify.Infrastructure/Jobs/TempUploadsCleanupJob.cs @@ -1,10 +1,11 @@ using Mediator; using Musify.Application.UploadIntents; -namespace Musify.Infrastructure.Jobs; - -public class TempUploadsCleanupJob(IMediator mediator) +namespace Musify.Infrastructure.Jobs { - public async Task RunAsync(CancellationToken cancellationToken) => - await mediator.Send(new DeleteStaleTempUploadsCommand(), cancellationToken); + public class TempUploadsCleanupJob(IMediator mediator) + { + public async Task RunAsync(CancellationToken cancellationToken) => + await mediator.Send(new DeleteStaleTempUploadsCommand(), cancellationToken); + } } diff --git a/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs b/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs index 3a6d37d..66c46c1 100644 --- a/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs +++ b/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs @@ -1,10 +1,11 @@ using Mediator; using Musify.Application.UploadIntents; -namespace Musify.Infrastructure.Jobs; - -public class UploadIntentExpirationJob(IMediator mediator) +namespace Musify.Infrastructure.Jobs { - public async Task RunAsync(CancellationToken cancellationToken) => - await mediator.Send(new ExpireUploadIntentsCommand(), cancellationToken); + public class UploadIntentExpirationJob(IMediator mediator) + { + public async Task RunAsync(CancellationToken cancellationToken) => + await mediator.Send(new ExpireUploadIntentsCommand(), cancellationToken); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs index ae54c09..9bae866 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs @@ -3,29 +3,30 @@ using Microsoft.Extensions.Logging; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.Activities.Albums; - -internal class PublishAlbumPictureProcessingEventActivity( - IPublishEndpoint publishEndpoint, - ILogger logger) - : IExecuteActivity +namespace Musify.Infrastructure.MassTransit.Activities.Albums { - public const string ExecuteEndpointName = "publish-album-picture-processing-event"; - - public async Task Execute(ExecuteContext executeContext) + internal class PublishAlbumPictureProcessingEventActivity( + IPublishEndpoint publishEndpoint, + ILogger logger) + : IExecuteActivity { - var arguments = executeContext.Arguments; + public const string ExecuteEndpointName = "publish-album-picture-processing-event"; - await publishEndpoint.Publish( - new UpdateAlbumPictureEvent( - arguments.AlbumId, - arguments.Bucket, - arguments.FinalPictureKey, - arguments.Sizes), - executeContext.CancellationToken); + public async Task Execute(ExecuteContext executeContext) + { + var arguments = executeContext.Arguments; - logger.LogInformation("Published picture processing event for album {AlbumId}", arguments.AlbumId); + await publishEndpoint.Publish( + new UpdateAlbumPictureEvent( + arguments.AlbumId, + arguments.Bucket, + arguments.FinalPictureKey, + arguments.Sizes), + executeContext.CancellationToken); - return executeContext.Completed(); + logger.LogInformation("Published picture processing event for album {AlbumId}", arguments.AlbumId); + + return executeContext.Completed(); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs index 9b38fb0..f0b102b 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs @@ -5,43 +5,44 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.RoutingSlip; -namespace Musify.Infrastructure.MassTransit.Activities.Audio; - -internal class GenerateAudioWorkflowPathsActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity +namespace Musify.Infrastructure.MassTransit.Activities.Audio { - public const string ExecuteEndpointName = "generate-audio-workflow-paths"; - - public async Task Execute(ExecuteContext executeContext) + internal class GenerateAudioWorkflowPathsActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity { - var arguments = executeContext.Arguments; - var track = await TrackAudioStatus.LoadAsync(database, arguments.TrackId, executeContext.CancellationToken); + public const string ExecuteEndpointName = "generate-audio-workflow-paths"; - try + public async Task Execute(ExecuteContext executeContext) { - track.Audio.TranscodeStatus = ProcessingStatus.Processing; + var arguments = executeContext.Arguments; + var track = await TrackAudioStatus.LoadAsync(database, arguments.TrackId, executeContext.CancellationToken); - var workingDirectory = Path.Combine(arguments.TemporaryRootDirectory, Guid.NewGuid().ToString()); - var sourceFilePath = Path.Combine(workingDirectory, Path.GetFileName(arguments.SourceKey)); - - Directory.CreateDirectory(workingDirectory); - await database.SaveChangesAsync(executeContext.CancellationToken); - - logger.LogDebug("Generated audio workflow paths in {WorkingDirectory}", workingDirectory); - - return executeContext.CompletedWithVariables(new Dictionary + try { - [RoutingSlipVariableNames.Workflow.TemporalDirectory] = workingDirectory, - [RoutingSlipVariableNames.Audio.SourceFilePath] = sourceFilePath, - [RoutingSlipVariableNames.Audio.TranscodedDirectory] = workingDirectory - }); - } - catch - { - await TrackAudioStatus.MarkFailedAsync(database, arguments.TrackId, executeContext.CancellationToken); - throw; + track.Audio.TranscodeStatus = ProcessingStatus.Processing; + + var workingDirectory = Path.Combine(arguments.TemporaryRootDirectory, Guid.NewGuid().ToString()); + var sourceFilePath = Path.Combine(workingDirectory, Path.GetFileName(arguments.SourceKey)); + + Directory.CreateDirectory(workingDirectory); + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogDebug("Generated audio workflow paths in {WorkingDirectory}", workingDirectory); + + return executeContext.CompletedWithVariables(new Dictionary + { + [RoutingSlipVariableNames.Workflow.TemporalDirectory] = workingDirectory, + [RoutingSlipVariableNames.Audio.SourceFilePath] = sourceFilePath, + [RoutingSlipVariableNames.Audio.TranscodedDirectory] = workingDirectory + }); + } + catch + { + await TrackAudioStatus.MarkFailedAsync(database, arguments.TrackId, executeContext.CancellationToken); + throw; + } } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TrackAudioStatus.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TrackAudioStatus.cs index 7f40abc..ba69169 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TrackAudioStatus.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TrackAudioStatus.cs @@ -3,22 +3,23 @@ using Musify.Application.Contracts; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Infrastructure.MassTransit.Activities.Audio; - -/// Shared bookkeeping of the audio processing activities. -internal static class TrackAudioStatus +namespace Musify.Infrastructure.MassTransit.Activities.Audio { - public static async Task LoadAsync(IDatabase database, Guid trackId, CancellationToken cancellationToken) => - await database.Tracks.SingleOrDefaultAsync(track => track.Id == trackId, cancellationToken) - ?? throw new InvalidOperationException($"Track with id {trackId} not found"); - - public static async Task MarkFailedAsync(IDatabase database, Guid trackId, CancellationToken cancellationToken) + /// Shared bookkeeping of the audio processing activities. + internal static class TrackAudioStatus { - var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == trackId, cancellationToken); - if (track == null) - return; + public static async Task LoadAsync(IDatabase database, Guid trackId, CancellationToken cancellationToken) => + await database.Tracks.SingleOrDefaultAsync(track => track.Id == trackId, cancellationToken) + ?? throw new InvalidOperationException($"Track with id {trackId} not found"); - track.Audio.TranscodeStatus = ProcessingStatus.Failed; - await database.SaveChangesAsync(cancellationToken); + public static async Task MarkFailedAsync(IDatabase database, Guid trackId, CancellationToken cancellationToken) + { + var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == trackId, cancellationToken); + if (track == null) + return; + + track.Audio.TranscodeStatus = ProcessingStatus.Failed; + await database.SaveChangesAsync(cancellationToken); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs index ee7e5d1..1321e01 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs @@ -5,61 +5,62 @@ using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; using Musify.Infrastructure.MassTransit.RoutingSlip; -namespace Musify.Infrastructure.MassTransit.Activities.Audio; - -internal class TranscodeAudioActivity( - IDatabase database, - IAudioTranscoderService audioTranscoder, - ILogger logger) - : IActivity +namespace Musify.Infrastructure.MassTransit.Activities.Audio { - public const string ExecuteEndpointName = "transcode-audio"; - - public async Task Execute(ExecuteContext executeContext) + internal class TranscodeAudioActivity( + IDatabase database, + IAudioTranscoderService audioTranscoder, + ILogger logger) + : IActivity { - var arguments = executeContext.Arguments; - var sourceFilePath = executeContext.GetVariable(arguments.SourceFilePathVariable); - ArgumentNullException.ThrowIfNull(sourceFilePath); - var workingDirectory = executeContext.GetVariable(arguments.WorkingDirectoryVariable); - ArgumentNullException.ThrowIfNull(workingDirectory); + public const string ExecuteEndpointName = "transcode-audio"; - await TrackAudioStatus.LoadAsync(database, arguments.TrackId, executeContext.CancellationToken); - - try + public async Task Execute(ExecuteContext executeContext) { - AudioTranscodeResult transcodeResult; - await using (var fileStream = File.OpenRead(sourceFilePath)) + var arguments = executeContext.Arguments; + var sourceFilePath = executeContext.GetVariable(arguments.SourceFilePathVariable); + ArgumentNullException.ThrowIfNull(sourceFilePath); + var workingDirectory = executeContext.GetVariable(arguments.WorkingDirectoryVariable); + ArgumentNullException.ThrowIfNull(workingDirectory); + + await TrackAudioStatus.LoadAsync(database, arguments.TrackId, executeContext.CancellationToken); + + try { - transcodeResult = await audioTranscoder.TranscodeToAudioFileAsync( - fileStream, workingDirectory, executeContext.CancellationToken); + AudioTranscodeResult transcodeResult; + await using (var fileStream = File.OpenRead(sourceFilePath)) + { + transcodeResult = await audioTranscoder.TranscodeToAudioFileAsync( + fileStream, workingDirectory, executeContext.CancellationToken); + } + + if (transcodeResult.StatusCode != 0) + throw new InvalidOperationException($"ffmpeg transcoding failed with exit code {transcodeResult.StatusCode}."); + + File.Delete(sourceFilePath); + + return executeContext.CompletedWithVariables(new TranscodeAudioLog(workingDirectory), new Dictionary + { + [RoutingSlipVariableNames.Audio.DurationSeconds] = transcodeResult.Duration.TotalSeconds + }); } - - if (transcodeResult.StatusCode != 0) - throw new InvalidOperationException($"ffmpeg transcoding failed with exit code {transcodeResult.StatusCode}."); - - File.Delete(sourceFilePath); - - return executeContext.CompletedWithVariables(new TranscodeAudioLog(workingDirectory), new Dictionary + catch { - [RoutingSlipVariableNames.Audio.DurationSeconds] = transcodeResult.Duration.TotalSeconds - }); + await TrackAudioStatus.MarkFailedAsync(database, arguments.TrackId, executeContext.CancellationToken); + throw; + } } - catch + + public Task Compensate(CompensateContext compensateContext) { - await TrackAudioStatus.MarkFailedAsync(database, arguments.TrackId, executeContext.CancellationToken); - throw; + var workingDirectory = compensateContext.Log.WorkingDirectory; + + if (Directory.Exists(workingDirectory)) + Directory.Delete(workingDirectory, recursive: true); + else + logger.LogWarning("Working directory {WorkingDirectory} not found during compensation", workingDirectory); + + return Task.FromResult(compensateContext.Compensated()); } } - - public Task Compensate(CompensateContext compensateContext) - { - var workingDirectory = compensateContext.Log.WorkingDirectory; - - if (Directory.Exists(workingDirectory)) - Directory.Delete(workingDirectory, recursive: true); - else - logger.LogWarning("Working directory {WorkingDirectory} not found during compensation", workingDirectory); - - return Task.FromResult(compensateContext.Compensated()); - } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs index d683d09..0771916 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs @@ -6,61 +6,62 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Audio; - -internal class UpdateTrackAudioActivity( - IDatabase database, - IPublishEndpoint publishEndpoint, - ILogger logger) - : IActivity +namespace Musify.Infrastructure.MassTransit.Activities.Audio { - public const string ExecuteEndpointName = "update-track-audio"; - - public async Task Execute(ExecuteContext executeContext) + internal class UpdateTrackAudioActivity( + IDatabase database, + IPublishEndpoint publishEndpoint, + ILogger logger) + : IActivity { - var arguments = executeContext.Arguments; - var track = await TrackAudioStatus.LoadAsync(database, arguments.TrackId, executeContext.CancellationToken); + public const string ExecuteEndpointName = "update-track-audio"; - try + public async Task Execute(ExecuteContext executeContext) { - if (string.IsNullOrWhiteSpace(arguments.AudioFolderKey)) - throw new InvalidOperationException($"Audio folder key is empty for track {track.Id}"); + var arguments = executeContext.Arguments; + var track = await TrackAudioStatus.LoadAsync(database, arguments.TrackId, executeContext.CancellationToken); - var durationSeconds = executeContext.GetVariable(arguments.DurationSecondsVariable) - ?? throw new InvalidOperationException($"Missing routing slip variable {arguments.DurationSecondsVariable}"); + try + { + if (string.IsNullOrWhiteSpace(arguments.AudioFolderKey)) + throw new InvalidOperationException($"Audio folder key is empty for track {track.Id}"); - var log = new UpdateTrackAudioLog(track.Id, track.Audio.FolderName, track.DurationSeconds); + var durationSeconds = executeContext.GetVariable(arguments.DurationSecondsVariable) + ?? throw new InvalidOperationException($"Missing routing slip variable {arguments.DurationSecondsVariable}"); - track.Audio.FolderName = Path.GetFileName(arguments.AudioFolderKey); - track.DurationSeconds = durationSeconds; - track.Audio.TranscodeStatus = ProcessingStatus.Completed; - await database.SaveChangesAsync(executeContext.CancellationToken); + var log = new UpdateTrackAudioLog(track.Id, track.Audio.FolderName, track.DurationSeconds); - logger.LogInformation("Updated track {TrackId} audio", arguments.TrackId); + track.Audio.FolderName = Path.GetFileName(arguments.AudioFolderKey); + track.DurationSeconds = durationSeconds; + track.Audio.TranscodeStatus = ProcessingStatus.Completed; + await database.SaveChangesAsync(executeContext.CancellationToken); - await publishEndpoint.Publish(new TrackAudioProcessed(arguments.TrackId), executeContext.CancellationToken); + logger.LogInformation("Updated track {TrackId} audio", arguments.TrackId); - return executeContext.Completed(log); + await publishEndpoint.Publish(new TrackAudioProcessed(arguments.TrackId), executeContext.CancellationToken); + + return executeContext.Completed(log); + } + catch + { + await TrackAudioStatus.MarkFailedAsync(database, arguments.TrackId, executeContext.CancellationToken); + throw; + } } - catch + + public async Task Compensate(CompensateContext compensateContext) { - await TrackAudioStatus.MarkFailedAsync(database, arguments.TrackId, executeContext.CancellationToken); - throw; + var log = compensateContext.Log; + + var track = await TrackAudioStatus.LoadAsync(database, log.TrackId, compensateContext.CancellationToken); + track.Audio.FolderName = log.PreviousAudioFolderName; + track.DurationSeconds = log.PreviousDurationSeconds; + track.Audio.TranscodeStatus = ProcessingStatus.Failed; + await database.SaveChangesAsync(compensateContext.CancellationToken); + + logger.LogInformation("Compensated track {TrackId} audio", log.TrackId); + + return compensateContext.Compensated(); } } - - public async Task Compensate(CompensateContext compensateContext) - { - var log = compensateContext.Log; - - var track = await TrackAudioStatus.LoadAsync(database, log.TrackId, compensateContext.CancellationToken); - track.Audio.FolderName = log.PreviousAudioFolderName; - track.DurationSeconds = log.PreviousDurationSeconds; - track.Audio.TranscodeStatus = ProcessingStatus.Failed; - await database.SaveChangesAsync(compensateContext.CancellationToken); - - logger.LogInformation("Compensated track {TrackId} audio", log.TrackId); - - return compensateContext.Compensated(); - } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs index 5662355..c7340cd 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs @@ -4,45 +4,46 @@ using Musify.Application.Contracts; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Files; - -internal class CopyFileInBucketActivity( - IStorageService storageService, - ILogger logger) - : IActivity +namespace Musify.Infrastructure.MassTransit.Activities.Files { - public const string ExecuteEndpointName = "copy-file-in-bucket"; - - public async Task Execute(ExecuteContext executeContext) + internal class CopyFileInBucketActivity( + IStorageService storageService, + ILogger logger) + : IActivity { - await storageService.CopyFileAsync( - executeContext.Arguments.SourceBucket, - executeContext.Arguments.SourceKey, - executeContext.Arguments.DestinationBucket, - executeContext.Arguments.DestinationKey, - executeContext.CancellationToken); + public const string ExecuteEndpointName = "copy-file-in-bucket"; - return executeContext.Completed(new CopyFileInBucketLog( - executeContext.Arguments.DestinationBucket, - executeContext.Arguments.DestinationKey)); - } - - public async Task Compensate(CompensateContext compensateContext) - { - try + public async Task Execute(ExecuteContext executeContext) { - await storageService.RemoveFileAsync( - compensateContext.Log.DestinationBucket, - compensateContext.Log.DestinationKey, - compensateContext.CancellationToken); - return compensateContext.Compensated(); + await storageService.CopyFileAsync( + executeContext.Arguments.SourceBucket, + executeContext.Arguments.SourceKey, + executeContext.Arguments.DestinationBucket, + executeContext.Arguments.DestinationKey, + executeContext.CancellationToken); + + return executeContext.Completed(new CopyFileInBucketLog( + executeContext.Arguments.DestinationBucket, + executeContext.Arguments.DestinationKey)); } - catch (Exception exception) + + public async Task Compensate(CompensateContext compensateContext) { - logger.LogError(exception, "Failed to compensate copied file {Bucket}/{Key}", - compensateContext.Log.DestinationBucket, - compensateContext.Log.DestinationKey); - return compensateContext.Failed(exception); + try + { + await storageService.RemoveFileAsync( + compensateContext.Log.DestinationBucket, + compensateContext.Log.DestinationKey, + compensateContext.CancellationToken); + return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate copied file {Bucket}/{Key}", + compensateContext.Log.DestinationBucket, + compensateContext.Log.DestinationKey); + return compensateContext.Failed(exception); + } } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs index 3faab94..67934db 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs @@ -4,52 +4,53 @@ using Musify.Application.Contracts; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Files; - -internal class DownloadFileFromBucketActivity( - IStorageService storageService, - ILogger logger) - : IActivity +namespace Musify.Infrastructure.MassTransit.Activities.Files { - public const string ExecuteEndpointName = "download-file-from-bucket"; - - public async Task Execute(ExecuteContext executeContext) + internal class DownloadFileFromBucketActivity( + IStorageService storageService, + ILogger logger) + : IActivity { - var destinationPath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); - ArgumentNullException.ThrowIfNull(destinationPath); + public const string ExecuteEndpointName = "download-file-from-bucket"; - using var fileStream = await storageService.GetFileAsync( - executeContext.Arguments.Bucket, - executeContext.Arguments.Key, - executeContext.CancellationToken) - ?? throw new FileNotFoundException($"Object {executeContext.Arguments.Bucket}/{executeContext.Arguments.Key} was not found."); - - var destinationDirectory = Path.GetDirectoryName(destinationPath); - if (!string.IsNullOrWhiteSpace(destinationDirectory)) - Directory.CreateDirectory(destinationDirectory); - - if (fileStream.CanSeek) - fileStream.Position = 0; - - using var destinationStream = File.Create(destinationPath); - await fileStream.CopyToAsync(destinationStream, executeContext.CancellationToken); - return executeContext.Completed(new DownloadFileFromBucketLog( - executeContext.Arguments.Bucket, - executeContext.Arguments.Key, - destinationPath)); - } - - public Task Compensate(CompensateContext compensateContext) - { - try + public async Task Execute(ExecuteContext executeContext) { - File.Delete(compensateContext.Log.DestinationFilePath); - return Task.FromResult(compensateContext.Compensated()); + var destinationPath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); + ArgumentNullException.ThrowIfNull(destinationPath); + + using var fileStream = await storageService.GetFileAsync( + executeContext.Arguments.Bucket, + executeContext.Arguments.Key, + executeContext.CancellationToken) + ?? throw new FileNotFoundException($"Object {executeContext.Arguments.Bucket}/{executeContext.Arguments.Key} was not found."); + + var destinationDirectory = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrWhiteSpace(destinationDirectory)) + Directory.CreateDirectory(destinationDirectory); + + if (fileStream.CanSeek) + fileStream.Position = 0; + + using var destinationStream = File.Create(destinationPath); + await fileStream.CopyToAsync(destinationStream, executeContext.CancellationToken); + return executeContext.Completed(new DownloadFileFromBucketLog( + executeContext.Arguments.Bucket, + executeContext.Arguments.Key, + destinationPath)); } - catch (Exception exception) + + public Task Compensate(CompensateContext compensateContext) { - logger.LogError(exception, "Failed to compensate downloaded file {DestinationFilePath}", compensateContext.Log.DestinationFilePath); - return Task.FromResult(compensateContext.Failed(exception)); + try + { + File.Delete(compensateContext.Log.DestinationFilePath); + return Task.FromResult(compensateContext.Compensated()); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate downloaded file {DestinationFilePath}", compensateContext.Log.DestinationFilePath); + return Task.FromResult(compensateContext.Failed(exception)); + } } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs index e5fce5d..7432997 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs @@ -5,34 +5,35 @@ using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.Activities.Files; - -internal class RemoveFileFromBucketActivity( - IStorageService storageService, - ILogger logger) - : IExecuteActivity +namespace Musify.Infrastructure.MassTransit.Activities.Files { - public const string ExecuteEndpointName = "remove-file-from-bucket"; - - public async Task Execute(ExecuteContext executeContext) + internal class RemoveFileFromBucketActivity( + IStorageService storageService, + ILogger logger) + : IExecuteActivity { - try + public const string ExecuteEndpointName = "remove-file-from-bucket"; + + public async Task Execute(ExecuteContext executeContext) { - await storageService.RemoveFileAsync( - executeContext.Arguments.Bucket, - executeContext.Arguments.Key, - executeContext.CancellationToken); - return executeContext.Completed(); - } - catch (AmazonS3Exception exception) when ( - exception.StatusCode == HttpStatusCode.NotFound - || string.Equals(exception.ErrorCode, "NoSuchKey", StringComparison.OrdinalIgnoreCase)) - { - logger.LogInformation( - "Key {Key} not found in bucket {Bucket}, skipping", - executeContext.Arguments.Key, - executeContext.Arguments.Bucket); - return executeContext.Completed(); + try + { + await storageService.RemoveFileAsync( + executeContext.Arguments.Bucket, + executeContext.Arguments.Key, + executeContext.CancellationToken); + return executeContext.Completed(); + } + catch (AmazonS3Exception exception) when ( + exception.StatusCode == HttpStatusCode.NotFound + || string.Equals(exception.ErrorCode, "NoSuchKey", StringComparison.OrdinalIgnoreCase)) + { + logger.LogInformation( + "Key {Key} not found in bucket {Bucket}, skipping", + executeContext.Arguments.Key, + executeContext.Arguments.Bucket); + return executeContext.Completed(); + } } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs index 7b5f310..8bffd7c 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs @@ -4,51 +4,52 @@ using Musify.Application.Contracts; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Files; - -internal class TransferFilesToBucketActivity( - IStorageService storageService, - ILogger logger) - : IActivity +namespace Musify.Infrastructure.MassTransit.Activities.Files { - public const string ExecuteEndpointName = "transfer-files-to-bucket"; - - public async Task Execute(ExecuteContext executeContext) + internal class TransferFilesToBucketActivity( + IStorageService storageService, + ILogger logger) + : IActivity { - var folderPath = executeContext.GetVariable(executeContext.Arguments.SourceDirectoryVariable); - ArgumentNullException.ThrowIfNull(folderPath); - var destinationKey = executeContext.Arguments.DestinationKey; - ArgumentNullException.ThrowIfNull(destinationKey); + public const string ExecuteEndpointName = "transfer-files-to-bucket"; - var uploadedKeys = await storageService.TransferFilesAsync( - folderPath, - executeContext.Arguments.DestinationBucket, - destinationKey, - executeContext.CancellationToken); - - return executeContext.Completed(new TransferFilesToBucketLog( - executeContext.Arguments.DestinationBucket, - uploadedKeys.ToArray())); - } - - public async Task Compensate(CompensateContext compensateContext) - { - try + public async Task Execute(ExecuteContext executeContext) { - foreach (var uploadedKey in compensateContext.Log.UploadedKeys) - { - await storageService.RemoveFileAsync( - compensateContext.Log.DestinationBucket, - uploadedKey, - compensateContext.CancellationToken); - } + var folderPath = executeContext.GetVariable(executeContext.Arguments.SourceDirectoryVariable); + ArgumentNullException.ThrowIfNull(folderPath); + var destinationKey = executeContext.Arguments.DestinationKey; + ArgumentNullException.ThrowIfNull(destinationKey); - return compensateContext.Compensated(); + var uploadedKeys = await storageService.TransferFilesAsync( + folderPath, + executeContext.Arguments.DestinationBucket, + destinationKey, + executeContext.CancellationToken); + + return executeContext.Completed(new TransferFilesToBucketLog( + executeContext.Arguments.DestinationBucket, + uploadedKeys.ToArray())); } - catch (Exception exception) + + public async Task Compensate(CompensateContext compensateContext) { - logger.LogError(exception, "Failed to compensate file transfers to {DestinationBucket}", compensateContext.Log.DestinationBucket); - return compensateContext.Failed(exception); + try + { + foreach (var uploadedKey in compensateContext.Log.UploadedKeys) + { + await storageService.RemoveFileAsync( + compensateContext.Log.DestinationBucket, + uploadedKey, + compensateContext.CancellationToken); + } + + return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate file transfers to {DestinationBucket}", compensateContext.Log.DestinationBucket); + return compensateContext.Failed(exception); + } } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs index f66d1db..c580bf3 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs @@ -4,49 +4,50 @@ using Musify.Application.Contracts; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Files; - -internal class UploadFileToBucketActivity(IStorageService storageService, - ILogger logger) - : IActivity +namespace Musify.Infrastructure.MassTransit.Activities.Files { - public const string ExecuteEndpointName = "upload-file-to-bucket"; - - public async Task Execute(ExecuteContext executeContext) + internal class UploadFileToBucketActivity(IStorageService storageService, + ILogger logger) + : IActivity { - var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.FilePathVariable); - ArgumentNullException.ThrowIfNull(sourceFilePath); - var fileName = Path.GetFileName(sourceFilePath); - var destinationKey = string.Join('/', new[] { executeContext.Arguments.DestinationRoute, fileName } - .Where(static s => !string.IsNullOrWhiteSpace(s)) - .Select(static s => s.Trim().Trim('/', '\\'))); + public const string ExecuteEndpointName = "upload-file-to-bucket"; - await storageService.UploadFileAsync( - sourceFilePath, - executeContext.Arguments.DestinationBucket, - destinationKey, - executeContext.CancellationToken); - return executeContext.CompletedWithVariables(new UploadFileToBucketLog( - executeContext.Arguments.DestinationBucket, - destinationKey)); - } - - public async Task Compensate(CompensateContext compensateContext) - { - try + public async Task Execute(ExecuteContext executeContext) { - await storageService.RemoveFileAsync( - compensateContext.Log.DestinationBucket, - compensateContext.Log.DestinationKey, - compensateContext.CancellationToken); - return compensateContext.Compensated(); + var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.FilePathVariable); + ArgumentNullException.ThrowIfNull(sourceFilePath); + var fileName = Path.GetFileName(sourceFilePath); + var destinationKey = string.Join('/', new[] { executeContext.Arguments.DestinationRoute, fileName } + .Where(static s => !string.IsNullOrWhiteSpace(s)) + .Select(static s => s.Trim().Trim('/', '\\'))); + + await storageService.UploadFileAsync( + sourceFilePath, + executeContext.Arguments.DestinationBucket, + destinationKey, + executeContext.CancellationToken); + return executeContext.CompletedWithVariables(new UploadFileToBucketLog( + executeContext.Arguments.DestinationBucket, + destinationKey)); } - catch (Exception exception) + + public async Task Compensate(CompensateContext compensateContext) { - logger.LogError(exception, "Error compensating file upload for {Bucket}/{Key}", - compensateContext.Log.DestinationBucket, - compensateContext.Log.DestinationKey); - return compensateContext.Failed(exception); + try + { + await storageService.RemoveFileAsync( + compensateContext.Log.DestinationBucket, + compensateContext.Log.DestinationKey, + compensateContext.CancellationToken); + return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Error compensating file upload for {Bucket}/{Key}", + compensateContext.Log.DestinationBucket, + compensateContext.Log.DestinationKey); + return compensateContext.Failed(exception); + } } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/LifeCycle/LifeCycleActivities.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/LifeCycle/LifeCycleActivities.cs index a69d39e..52bab3e 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/LifeCycle/LifeCycleActivities.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/LifeCycle/LifeCycleActivities.cs @@ -6,92 +6,93 @@ using Musify.Domain.Abstractions; using Musify.Domain.Entities; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.Activities.LifeCycle; - -/// Sets the life-cycle status of an entity (removing, failed, ...). A missing entity is skipped. -internal abstract class MarkLifeCycleActivity(IDatabase database, ILogger logger) - : IExecuteActivity - where TEntity : class, IHasLifeCycle +namespace Musify.Infrastructure.MassTransit.Activities.LifeCycle { - public async Task Execute(ExecuteContext executeContext) + /// Sets the life-cycle status of an entity (removing, failed, ...). A missing entity is skipped. + internal abstract class MarkLifeCycleActivity(IDatabase database, ILogger logger) + : IExecuteActivity + where TEntity : class, IHasLifeCycle { - var arguments = executeContext.Arguments; - var entity = await database.Set() - .SingleOrDefaultAsync(e => e.Id == arguments.Id, executeContext.CancellationToken); - - if (entity == null) + public async Task Execute(ExecuteContext executeContext) { - logger.LogInformation("{Entity} {Id} not found while marking it as {Status}, skipping", typeof(TEntity).Name, arguments.Id, arguments.Status); + var arguments = executeContext.Arguments; + var entity = await database.Set() + .SingleOrDefaultAsync(e => e.Id == arguments.Id, executeContext.CancellationToken); + + if (entity == null) + { + logger.LogInformation("{Entity} {Id} not found while marking it as {Status}, skipping", typeof(TEntity).Name, arguments.Id, arguments.Status); + return executeContext.Completed(); + } + + entity.LifeCycleStatus = arguments.Status; + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Marked {Entity} {Id} as {Status}", typeof(TEntity).Name, arguments.Id, arguments.Status); return executeContext.Completed(); } - - entity.LifeCycleStatus = arguments.Status; - await database.SaveChangesAsync(executeContext.CancellationToken); - - logger.LogInformation("Marked {Entity} {Id} as {Status}", typeof(TEntity).Name, arguments.Id, arguments.Status); - return executeContext.Completed(); } -} -/// Deletes an entity row. A missing entity is skipped. -internal abstract class DeleteEntityActivity(IDatabase database, ILogger logger) - : IExecuteActivity - where TEntity : class, IHasLifeCycle -{ - public async Task Execute(ExecuteContext executeContext) + /// Deletes an entity row. A missing entity is skipped. + internal abstract class DeleteEntityActivity(IDatabase database, ILogger logger) + : IExecuteActivity + where TEntity : class, IHasLifeCycle { - var id = executeContext.Arguments.Id; - var entity = await database.Set() - .SingleOrDefaultAsync(e => e.Id == id, executeContext.CancellationToken); - - if (entity == null) + public async Task Execute(ExecuteContext executeContext) { - logger.LogInformation("{Entity} {Id} not found while deleting it, skipping", typeof(TEntity).Name, id); + var id = executeContext.Arguments.Id; + var entity = await database.Set() + .SingleOrDefaultAsync(e => e.Id == id, executeContext.CancellationToken); + + if (entity == null) + { + logger.LogInformation("{Entity} {Id} not found while deleting it, skipping", typeof(TEntity).Name, id); + return executeContext.Completed(); + } + + database.Set().Remove(entity); + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Deleted {Entity} {Id} from the database", typeof(TEntity).Name, id); return executeContext.Completed(); } + } - database.Set().Remove(entity); - await database.SaveChangesAsync(executeContext.CancellationToken); + // One concrete activity per entity keeps a separate queue for each of them: + // the endpoint name is the kebab-cased class name. + internal sealed class MarkTrackLifeCycleActivity(IDatabase database, ILogger logger) + : MarkLifeCycleActivity(database, logger) + { + public const string ExecuteEndpointName = "mark-track-life-cycle"; + } - logger.LogInformation("Deleted {Entity} {Id} from the database", typeof(TEntity).Name, id); - return executeContext.Completed(); + internal sealed class MarkPlayListLifeCycleActivity(IDatabase database, ILogger logger) + : MarkLifeCycleActivity(database, logger) + { + public const string ExecuteEndpointName = "mark-play-list-life-cycle"; + } + + internal sealed class MarkAlbumLifeCycleActivity(IDatabase database, ILogger logger) + : MarkLifeCycleActivity(database, logger) + { + public const string ExecuteEndpointName = "mark-album-life-cycle"; + } + + internal sealed class DeleteTrackActivity(IDatabase database, ILogger logger) + : DeleteEntityActivity(database, logger) + { + public const string ExecuteEndpointName = "delete-track"; + } + + internal sealed class DeletePlayListActivity(IDatabase database, ILogger logger) + : DeleteEntityActivity(database, logger) + { + public const string ExecuteEndpointName = "delete-play-list"; + } + + internal sealed class DeleteAlbumActivity(IDatabase database, ILogger logger) + : DeleteEntityActivity(database, logger) + { + public const string ExecuteEndpointName = "delete-album"; } } - -// One concrete activity per entity keeps a separate queue for each of them: -// the endpoint name is the kebab-cased class name. -internal sealed class MarkTrackLifeCycleActivity(IDatabase database, ILogger logger) - : MarkLifeCycleActivity(database, logger) -{ - public const string ExecuteEndpointName = "mark-track-life-cycle"; -} - -internal sealed class MarkPlayListLifeCycleActivity(IDatabase database, ILogger logger) - : MarkLifeCycleActivity(database, logger) -{ - public const string ExecuteEndpointName = "mark-play-list-life-cycle"; -} - -internal sealed class MarkAlbumLifeCycleActivity(IDatabase database, ILogger logger) - : MarkLifeCycleActivity(database, logger) -{ - public const string ExecuteEndpointName = "mark-album-life-cycle"; -} - -internal sealed class DeleteTrackActivity(IDatabase database, ILogger logger) - : DeleteEntityActivity(database, logger) -{ - public const string ExecuteEndpointName = "delete-track"; -} - -internal sealed class DeletePlayListActivity(IDatabase database, ILogger logger) - : DeleteEntityActivity(database, logger) -{ - public const string ExecuteEndpointName = "delete-play-list"; -} - -internal sealed class DeleteAlbumActivity(IDatabase database, ILogger logger) - : DeleteEntityActivity(database, logger) -{ - public const string ExecuteEndpointName = "delete-album"; -} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs index 48305af..1022f68 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs @@ -3,37 +3,38 @@ using Microsoft.Extensions.Logging; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.RoutingSlip; -namespace Musify.Infrastructure.MassTransit.Activities.Pictures; - -internal class GeneratePictureWorkflowPathsActivity( - ILogger logger) - : IExecuteActivity +namespace Musify.Infrastructure.MassTransit.Activities.Pictures { - public const string ExecuteEndpointName = "generate-picture-workflow-paths"; - - internal const string ResizedPictureFileExtension = ".webp"; - - public Task Execute(ExecuteContext executeContext) + internal class GeneratePictureWorkflowPathsActivity( + ILogger logger) + : IExecuteActivity { - var workingDirectory = Path.Combine(executeContext.Arguments.TemporaryRootDirectory, Guid.NewGuid().ToString()); + public const string ExecuteEndpointName = "generate-picture-workflow-paths"; - var sourceFilePath = Path.Combine( - workingDirectory, Guid.NewGuid().ToString() + Path.GetExtension(executeContext.Arguments.SourceKey)); - var smallPictureFilePath = Path.Combine(workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension); - var mediumPictureFilePath = Path.Combine(workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension); - var largePictureFilePath = Path.Combine(workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension); + internal const string ResizedPictureFileExtension = ".webp"; - Directory.CreateDirectory(workingDirectory); - - logger.LogDebug("Generated picture workflow paths in {WorkingDirectory}", workingDirectory); - - return Task.FromResult(executeContext.CompletedWithVariables(new Dictionary + public Task Execute(ExecuteContext executeContext) { - [RoutingSlipVariableNames.Workflow.TemporalDirectory] = workingDirectory, - [RoutingSlipVariableNames.Picture.OriginalFilePath] = sourceFilePath, - [RoutingSlipVariableNames.Picture.SmallResizedFilePath] = smallPictureFilePath, - [RoutingSlipVariableNames.Picture.MediumResizedFilePath] = mediumPictureFilePath, - [RoutingSlipVariableNames.Picture.LargeResizedFilePath] = largePictureFilePath - })); + var workingDirectory = Path.Combine(executeContext.Arguments.TemporaryRootDirectory, Guid.NewGuid().ToString()); + + var sourceFilePath = Path.Combine( + workingDirectory, Guid.NewGuid().ToString() + Path.GetExtension(executeContext.Arguments.SourceKey)); + var smallPictureFilePath = Path.Combine(workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension); + var mediumPictureFilePath = Path.Combine(workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension); + var largePictureFilePath = Path.Combine(workingDirectory, Guid.NewGuid().ToString() + ResizedPictureFileExtension); + + Directory.CreateDirectory(workingDirectory); + + logger.LogDebug("Generated picture workflow paths in {WorkingDirectory}", workingDirectory); + + return Task.FromResult(executeContext.CompletedWithVariables(new Dictionary + { + [RoutingSlipVariableNames.Workflow.TemporalDirectory] = workingDirectory, + [RoutingSlipVariableNames.Picture.OriginalFilePath] = sourceFilePath, + [RoutingSlipVariableNames.Picture.SmallResizedFilePath] = smallPictureFilePath, + [RoutingSlipVariableNames.Picture.MediumResizedFilePath] = mediumPictureFilePath, + [RoutingSlipVariableNames.Picture.LargeResizedFilePath] = largePictureFilePath + })); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs index 94c7758..89bf098 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs @@ -4,60 +4,61 @@ using Musify.Application.Contracts; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Pictures; - -internal class ResizePictureActivity( - IPictureService pictureHandler, - ILogger logger) - : IActivity +namespace Musify.Infrastructure.MassTransit.Activities.Pictures { - public const string ExecuteEndpointName = "resize-picture"; - - public async Task Execute(ExecuteContext executeContext) + internal class ResizePictureActivity( + IPictureService pictureHandler, + ILogger logger) + : IActivity { - var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.SourceFilePathVariable); - ArgumentNullException.ThrowIfNull(sourceFilePath); - var destinationFilePath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); - ArgumentNullException.ThrowIfNull(destinationFilePath); + public const string ExecuteEndpointName = "resize-picture"; - if (!File.Exists(sourceFilePath)) + public async Task Execute(ExecuteContext executeContext) { - logger.LogWarning("Source file not found: {SourceFilePath}", sourceFilePath); - throw new FileNotFoundException($"Source file not found: {sourceFilePath}"); + var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.SourceFilePathVariable); + ArgumentNullException.ThrowIfNull(sourceFilePath); + var destinationFilePath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); + ArgumentNullException.ThrowIfNull(destinationFilePath); + + if (!File.Exists(sourceFilePath)) + { + logger.LogWarning("Source file not found: {SourceFilePath}", sourceFilePath); + throw new FileNotFoundException($"Source file not found: {sourceFilePath}"); + } + + await using var fileStream = File.OpenRead(sourceFilePath); + + await using var resizedPicture = await pictureHandler.ResizePictureAsWebpAsync( + fileStream, + executeContext.Arguments.Width, + executeContext.Arguments.Height, + executeContext.CancellationToken); + + var destinationDirectory = Path.GetDirectoryName(destinationFilePath); + if (!string.IsNullOrEmpty(destinationDirectory)) + Directory.CreateDirectory(destinationDirectory); + + await using var destinationStream = File.Create(destinationFilePath); + await resizedPicture.CopyToAsync(destinationStream, executeContext.CancellationToken); + + logger.LogDebug("Resized picture from {SourceFilePath} to {DestinationFilePath}", + sourceFilePath, + destinationFilePath); + return executeContext.Completed(new ResizePictureLog(destinationFilePath)); } - await using var fileStream = File.OpenRead(sourceFilePath); - - await using var resizedPicture = await pictureHandler.ResizePictureAsWebpAsync( - fileStream, - executeContext.Arguments.Width, - executeContext.Arguments.Height, - executeContext.CancellationToken); - - var destinationDirectory = Path.GetDirectoryName(destinationFilePath); - if (!string.IsNullOrEmpty(destinationDirectory)) - Directory.CreateDirectory(destinationDirectory); - - await using var destinationStream = File.Create(destinationFilePath); - await resizedPicture.CopyToAsync(destinationStream, executeContext.CancellationToken); - - logger.LogDebug("Resized picture from {SourceFilePath} to {DestinationFilePath}", - sourceFilePath, - destinationFilePath); - return executeContext.Completed(new ResizePictureLog(destinationFilePath)); - } - - public Task Compensate(CompensateContext compensateContext) - { - try + public Task Compensate(CompensateContext compensateContext) { - File.Delete(compensateContext.Log.DestinationFilePath); - return Task.FromResult(compensateContext.Compensated()); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to compensate resized file {DestinationFilePath}", compensateContext.Log.DestinationFilePath); - return Task.FromResult(compensateContext.Failed(exception)); + try + { + File.Delete(compensateContext.Log.DestinationFilePath); + return Task.FromResult(compensateContext.Compensated()); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate resized file {DestinationFilePath}", compensateContext.Log.DestinationFilePath); + return Task.FromResult(compensateContext.Failed(exception)); + } } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePicturesActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePicturesActivity.cs index 76d84f3..3536d55 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePicturesActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePicturesActivity.cs @@ -8,153 +8,154 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Pictures; - -internal sealed record PictureNames(string? Original, string? Small, string? Medium, string? Large); - -/// Stores the processed picture names on the owner and reports it; compensation restores the previous names. -internal abstract class UpdatePicturesActivity( - IDatabase database, - IPublishEndpoint publishEndpoint, - ILogger logger) - : IActivity - where TEntity : class +namespace Musify.Infrastructure.MassTransit.Activities.Pictures { - protected IDatabase Database { get; } = database; + internal sealed record PictureNames(string? Original, string? Small, string? Medium, string? Large); - protected abstract Task FindAsync(Guid id, CancellationToken cancellationToken); - - /// Reads the current names; throws when the entity has no pending picture. - protected abstract PictureNames Read(TEntity entity); - - protected abstract void Write(TEntity entity, PictureNames names, bool succeeded); - - protected abstract object ProcessedEvent(Guid id); - - public async Task Execute(ExecuteContext executeContext) + /// Stores the processed picture names on the owner and reports it; compensation restores the previous names. + internal abstract class UpdatePicturesActivity( + IDatabase database, + IPublishEndpoint publishEndpoint, + ILogger logger) + : IActivity + where TEntity : class { - var arguments = executeContext.Arguments; - var small = RequireVariable(executeContext, arguments.SmallPictureVariable); - var medium = RequireVariable(executeContext, arguments.MediumPictureVariable); - var large = RequireVariable(executeContext, arguments.LargePictureVariable); + protected IDatabase Database { get; } = database; - var entity = await FindAsync(arguments.SubjectId, executeContext.CancellationToken) - ?? throw new InvalidOperationException($"{typeof(TEntity).Name} {arguments.SubjectId} not found"); + protected abstract Task FindAsync(Guid id, CancellationToken cancellationToken); - var previous = Read(entity); - Write( - entity, - new PictureNames( - Path.GetFileName(arguments.OriginalPictureKey), - Path.GetFileName(small), - Path.GetFileName(medium), - Path.GetFileName(large)), - succeeded: true); - await Database.SaveChangesAsync(executeContext.CancellationToken); + /// Reads the current names; throws when the entity has no pending picture. + protected abstract PictureNames Read(TEntity entity); - logger.LogInformation("Updated {Entity} {Id} pictures", typeof(TEntity).Name, arguments.SubjectId); + protected abstract void Write(TEntity entity, PictureNames names, bool succeeded); - await publishEndpoint.Publish(ProcessedEvent(arguments.SubjectId), executeContext.CancellationToken); + protected abstract object ProcessedEvent(Guid id); - return executeContext.Completed(new UpdatePicturesLog( - arguments.SubjectId, previous.Original, previous.Small, previous.Medium, previous.Large)); - } - - public async Task Compensate(CompensateContext compensateContext) - { - var log = compensateContext.Log; - - try + public async Task Execute(ExecuteContext executeContext) { - var entity = await FindAsync(log.SubjectId, compensateContext.CancellationToken); - if (entity == null) + var arguments = executeContext.Arguments; + var small = RequireVariable(executeContext, arguments.SmallPictureVariable); + var medium = RequireVariable(executeContext, arguments.MediumPictureVariable); + var large = RequireVariable(executeContext, arguments.LargePictureVariable); + + var entity = await FindAsync(arguments.SubjectId, executeContext.CancellationToken) + ?? throw new InvalidOperationException($"{typeof(TEntity).Name} {arguments.SubjectId} not found"); + + var previous = Read(entity); + Write( + entity, + new PictureNames( + Path.GetFileName(arguments.OriginalPictureKey), + Path.GetFileName(small), + Path.GetFileName(medium), + Path.GetFileName(large)), + succeeded: true); + await Database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Updated {Entity} {Id} pictures", typeof(TEntity).Name, arguments.SubjectId); + + await publishEndpoint.Publish(ProcessedEvent(arguments.SubjectId), executeContext.CancellationToken); + + return executeContext.Completed(new UpdatePicturesLog( + arguments.SubjectId, previous.Original, previous.Small, previous.Medium, previous.Large)); + } + + public async Task Compensate(CompensateContext compensateContext) + { + var log = compensateContext.Log; + + try + { + var entity = await FindAsync(log.SubjectId, compensateContext.CancellationToken); + if (entity == null) + return compensateContext.Compensated(); + + var previous = new PictureNames(log.PreviousOriginalName, log.PreviousSmallName, log.PreviousMediumName, log.PreviousLargeName); + Write(entity, previous, succeeded: false); + await Database.SaveChangesAsync(compensateContext.CancellationToken); + return compensateContext.Compensated(); - - var previous = new PictureNames(log.PreviousOriginalName, log.PreviousSmallName, log.PreviousMediumName, log.PreviousLargeName); - Write(entity, previous, succeeded: false); - await Database.SaveChangesAsync(compensateContext.CancellationToken); - - return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate {Entity} {Id} pictures", typeof(TEntity).Name, log.SubjectId); + return compensateContext.Failed(exception); + } } - catch (Exception exception) + + protected static PictureNames ReadPictures(EntityPictures? pictures) => + pictures == null + ? throw new InvalidOperationException("The entity has no pending picture upload") + : new PictureNames(pictures.OriginalName, pictures.SmallName, pictures.MediumName, pictures.LargeName); + + protected static void WritePictures(EntityPictures? pictures, PictureNames names) { - logger.LogError(exception, "Failed to compensate {Entity} {Id} pictures", typeof(TEntity).Name, log.SubjectId); - return compensateContext.Failed(exception); + if (pictures == null) + return; + + pictures.OriginalName = names.Original ?? pictures.OriginalName; + pictures.SmallName = names.Small; + pictures.MediumName = names.Medium; + pictures.LargeName = names.Large; } + + private static string RequireVariable(ExecuteContext executeContext, string variableName) => + executeContext.GetVariable(variableName) + ?? throw new InvalidOperationException($"Missing routing slip variable {variableName}"); } - protected static PictureNames ReadPictures(EntityPictures? pictures) => - pictures == null - ? throw new InvalidOperationException("The entity has no pending picture upload") - : new PictureNames(pictures.OriginalName, pictures.SmallName, pictures.MediumName, pictures.LargeName); - - protected static void WritePictures(EntityPictures? pictures, PictureNames names) + internal sealed class UpdateTrackPictureActivity(IDatabase database, IPublishEndpoint publishEndpoint, ILogger logger) + : UpdatePicturesActivity(database, publishEndpoint, logger) { - if (pictures == null) - return; + public const string ExecuteEndpointName = "update-track-picture"; - pictures.OriginalName = names.Original ?? pictures.OriginalName; - pictures.SmallName = names.Small; - pictures.MediumName = names.Medium; - pictures.LargeName = names.Large; + protected override Task FindAsync(Guid id, CancellationToken cancellationToken) => + Database.Tracks.SingleOrDefaultAsync(track => track.Id == id, cancellationToken); + + protected override PictureNames Read(Track track) => + new(track.Pictures.OriginalName, track.Pictures.SmallName, track.Pictures.MediumName, track.Pictures.LargeName); + + protected override void Write(Track track, PictureNames names, bool succeeded) + { + track.Pictures.OriginalName = names.Original; + track.Pictures.SmallName = names.Small; + track.Pictures.MediumName = names.Medium; + track.Pictures.LargeName = names.Large; + track.Pictures.ProcessingStatus = succeeded ? ProcessingStatus.Completed : ProcessingStatus.Failed; + } + + protected override object ProcessedEvent(Guid id) => new TrackPictureProcessed(id); } - private static string RequireVariable(ExecuteContext executeContext, string variableName) => - executeContext.GetVariable(variableName) - ?? throw new InvalidOperationException($"Missing routing slip variable {variableName}"); -} - -internal sealed class UpdateTrackPictureActivity(IDatabase database, IPublishEndpoint publishEndpoint, ILogger logger) - : UpdatePicturesActivity(database, publishEndpoint, logger) -{ - public const string ExecuteEndpointName = "update-track-picture"; - - protected override Task FindAsync(Guid id, CancellationToken cancellationToken) => - Database.Tracks.SingleOrDefaultAsync(track => track.Id == id, cancellationToken); - - protected override PictureNames Read(Track track) => - new(track.Pictures.OriginalName, track.Pictures.SmallName, track.Pictures.MediumName, track.Pictures.LargeName); - - protected override void Write(Track track, PictureNames names, bool succeeded) + internal sealed class UpdatePlayListPictureActivity(IDatabase database, IPublishEndpoint publishEndpoint, ILogger logger) + : UpdatePicturesActivity(database, publishEndpoint, logger) { - track.Pictures.OriginalName = names.Original; - track.Pictures.SmallName = names.Small; - track.Pictures.MediumName = names.Medium; - track.Pictures.LargeName = names.Large; - track.Pictures.ProcessingStatus = succeeded ? ProcessingStatus.Completed : ProcessingStatus.Failed; + public const string ExecuteEndpointName = "update-play-list-picture"; + + protected override Task FindAsync(Guid id, CancellationToken cancellationToken) => + Database.PlayLists.SingleOrDefaultAsync(playList => playList.Id == id, cancellationToken); + + protected override PictureNames Read(PlayList playList) => ReadPictures(playList.Pictures); + + protected override void Write(PlayList playList, PictureNames names, bool succeeded) => + WritePictures(playList.Pictures, names); + + protected override object ProcessedEvent(Guid id) => new PlayListPictureProcessed(id); } - protected override object ProcessedEvent(Guid id) => new TrackPictureProcessed(id); -} + internal sealed class UpdateAlbumPictureActivity(IDatabase database, IPublishEndpoint publishEndpoint, ILogger logger) + : UpdatePicturesActivity(database, publishEndpoint, logger) + { + public const string ExecuteEndpointName = "update-album-picture"; -internal sealed class UpdatePlayListPictureActivity(IDatabase database, IPublishEndpoint publishEndpoint, ILogger logger) - : UpdatePicturesActivity(database, publishEndpoint, logger) -{ - public const string ExecuteEndpointName = "update-play-list-picture"; + protected override Task FindAsync(Guid id, CancellationToken cancellationToken) => + Database.Albums.SingleOrDefaultAsync(album => album.Id == id, cancellationToken); - protected override Task FindAsync(Guid id, CancellationToken cancellationToken) => - Database.PlayLists.SingleOrDefaultAsync(playList => playList.Id == id, cancellationToken); + protected override PictureNames Read(Album album) => ReadPictures(album.Pictures); - protected override PictureNames Read(PlayList playList) => ReadPictures(playList.Pictures); + protected override void Write(Album album, PictureNames names, bool succeeded) => + WritePictures(album.Pictures, names); - protected override void Write(PlayList playList, PictureNames names, bool succeeded) => - WritePictures(playList.Pictures, names); - - protected override object ProcessedEvent(Guid id) => new PlayListPictureProcessed(id); -} - -internal sealed class UpdateAlbumPictureActivity(IDatabase database, IPublishEndpoint publishEndpoint, ILogger logger) - : UpdatePicturesActivity(database, publishEndpoint, logger) -{ - public const string ExecuteEndpointName = "update-album-picture"; - - protected override Task FindAsync(Guid id, CancellationToken cancellationToken) => - Database.Albums.SingleOrDefaultAsync(album => album.Id == id, cancellationToken); - - protected override PictureNames Read(Album album) => ReadPictures(album.Pictures); - - protected override void Write(Album album, PictureNames names, bool succeeded) => - WritePictures(album.Pictures, names); - - protected override object ProcessedEvent(Guid id) => new AlbumPictureProcessed(id); + protected override object ProcessedEvent(Guid id) => new AlbumPictureProcessed(id); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs index c1b104c..dc2b771 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs @@ -3,29 +3,30 @@ using Microsoft.Extensions.Logging; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.Activities.PlayLists; - -internal class PublishPlayListPictureProcessingEventActivity( - IPublishEndpoint publishEndpoint, - ILogger logger) - : IExecuteActivity +namespace Musify.Infrastructure.MassTransit.Activities.PlayLists { - public const string ExecuteEndpointName = "publish-play-list-picture-processing-event"; - - public async Task Execute(ExecuteContext executeContext) + internal class PublishPlayListPictureProcessingEventActivity( + IPublishEndpoint publishEndpoint, + ILogger logger) + : IExecuteActivity { - var arguments = executeContext.Arguments; + public const string ExecuteEndpointName = "publish-play-list-picture-processing-event"; - await publishEndpoint.Publish( - new UpdatePlayListPictureEvent( - arguments.PlayListId, - arguments.Bucket, - arguments.FinalPictureKey, - arguments.Sizes), - executeContext.CancellationToken); + public async Task Execute(ExecuteContext executeContext) + { + var arguments = executeContext.Arguments; - logger.LogInformation("Published picture processing event for playlist {PlayListId}", arguments.PlayListId); + await publishEndpoint.Publish( + new UpdatePlayListPictureEvent( + arguments.PlayListId, + arguments.Bucket, + arguments.FinalPictureKey, + arguments.Sizes), + executeContext.CancellationToken); - return executeContext.Completed(); + logger.LogInformation("Published picture processing event for playlist {PlayListId}", arguments.PlayListId); + + return executeContext.Completed(); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs index 8cf6b1f..9b88b0e 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs @@ -3,38 +3,39 @@ using Microsoft.Extensions.Logging; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.Activities.Tracks; - -internal class PublishTrackProcessingEventsActivity( - IPublishEndpoint publishEndpoint, - ILogger logger) - : IExecuteActivity +namespace Musify.Infrastructure.MassTransit.Activities.Tracks { - public const string ExecuteEndpointName = "publish-track-processing-events"; - - public async Task Execute(ExecuteContext executeContext) + internal class PublishTrackProcessingEventsActivity( + IPublishEndpoint publishEndpoint, + ILogger logger) + : IExecuteActivity { - var arguments = executeContext.Arguments; + public const string ExecuteEndpointName = "publish-track-processing-events"; - await publishEndpoint.Publish( - new UpdateTrackPictureEvent( - arguments.TrackId, - arguments.Bucket, - arguments.FinalPictureKey, - arguments.Sizes), - executeContext.CancellationToken); + public async Task Execute(ExecuteContext executeContext) + { + var arguments = executeContext.Arguments; - await publishEndpoint.Publish( - new UpdateTrackAudioEvent( - arguments.TrackId, - arguments.Bucket, - arguments.FinalAudioKey, - arguments.Bucket, - arguments.AudioDestinationFolderKey), - executeContext.CancellationToken); + await publishEndpoint.Publish( + new UpdateTrackPictureEvent( + arguments.TrackId, + arguments.Bucket, + arguments.FinalPictureKey, + arguments.Sizes), + executeContext.CancellationToken); - logger.LogInformation("Published processing events for track {TrackId}", arguments.TrackId); + await publishEndpoint.Publish( + new UpdateTrackAudioEvent( + arguments.TrackId, + arguments.Bucket, + arguments.FinalAudioKey, + arguments.Bucket, + arguments.AudioDestinationFolderKey), + executeContext.CancellationToken); - return executeContext.Completed(); + logger.LogInformation("Published processing events for track {TrackId}", arguments.TrackId); + + return executeContext.Completed(); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/UploadIntents/ConsumeUploadIntentsActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/UploadIntents/ConsumeUploadIntentsActivity.cs index 24d8aa9..1582d32 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/UploadIntents/ConsumeUploadIntentsActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/UploadIntents/ConsumeUploadIntentsActivity.cs @@ -6,56 +6,57 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.UploadIntents; - -internal class ConsumeUploadIntentsActivity( - IDatabase database, - ILogger logger) - : IActivity +namespace Musify.Infrastructure.MassTransit.Activities.UploadIntents { - public const string ExecuteEndpointName = "consume-upload-intents"; - - public async Task Execute(ExecuteContext executeContext) + internal class ConsumeUploadIntentsActivity( + IDatabase database, + ILogger logger) + : IActivity { - var intentIds = executeContext.Arguments.IntentIds; + public const string ExecuteEndpointName = "consume-upload-intents"; - var intents = await database.UploadIntents - .Where(i => intentIds.Contains(i.Id)) - .ToListAsync(executeContext.CancellationToken); - - foreach (var intent in intents) - intent.Status = UploadIntentStatus.Consumed; - - await database.SaveChangesAsync(executeContext.CancellationToken); - - logger.LogInformation("Marked {Count} upload intent(s) as consumed", intents.Count); - - return executeContext.Completed(new ConsumeUploadIntentsLog(intentIds)); - } - - public async Task Compensate(CompensateContext compensateContext) - { - try + public async Task Execute(ExecuteContext executeContext) { - var intentIds = compensateContext.Log.IntentIds; + var intentIds = executeContext.Arguments.IntentIds; var intents = await database.UploadIntents .Where(i => intentIds.Contains(i.Id)) - .ToListAsync(compensateContext.CancellationToken); + .ToListAsync(executeContext.CancellationToken); foreach (var intent in intents) - intent.Status = UploadIntentStatus.Issued; + intent.Status = UploadIntentStatus.Consumed; - await database.SaveChangesAsync(compensateContext.CancellationToken); + await database.SaveChangesAsync(executeContext.CancellationToken); - logger.LogInformation("Reverted {Count} upload intent(s) back to Issued", intents.Count); + logger.LogInformation("Marked {Count} upload intent(s) as consumed", intents.Count); - return compensateContext.Compensated(); + return executeContext.Completed(new ConsumeUploadIntentsLog(intentIds)); } - catch (Exception exception) + + public async Task Compensate(CompensateContext compensateContext) { - logger.LogError(exception, "Failed to compensate upload intent consumption"); - return compensateContext.Failed(exception); + try + { + var intentIds = compensateContext.Log.IntentIds; + + var intents = await database.UploadIntents + .Where(i => intentIds.Contains(i.Id)) + .ToListAsync(compensateContext.CancellationToken); + + foreach (var intent in intents) + intent.Status = UploadIntentStatus.Issued; + + await database.SaveChangesAsync(compensateContext.CancellationToken); + + logger.LogInformation("Reverted {Count} upload intent(s) back to Issued", intents.Count); + + return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate upload intent consumption"); + return compensateContext.Failed(exception); + } } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/ConsumeUploadIntentsArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/ConsumeUploadIntentsArguments.cs index d133085..70cd9c2 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/ConsumeUploadIntentsArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/ConsumeUploadIntentsArguments.cs @@ -1,4 +1,5 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record ConsumeUploadIntentsArguments( - Guid[] IntentIds); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record ConsumeUploadIntentsArguments( + Guid[] IntentIds); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/CopyFileInBucketArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/CopyFileInBucketArguments.cs index d73c5ea..ade186a 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/CopyFileInBucketArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/CopyFileInBucketArguments.cs @@ -1,7 +1,8 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record CopyFileInBucketArguments( - string SourceBucket, - string SourceKey, - string DestinationBucket, - string DestinationKey); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record CopyFileInBucketArguments( + string SourceBucket, + string SourceKey, + string DestinationBucket, + string DestinationKey); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteEntityArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteEntityArguments.cs index b84ea8b..257ceee 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteEntityArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteEntityArguments.cs @@ -1,3 +1,4 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -internal record DeleteEntityArguments(Guid Id); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + internal record DeleteEntityArguments(Guid Id); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromBucketArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromBucketArguments.cs index 853ba02..e687d24 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromBucketArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromBucketArguments.cs @@ -1,6 +1,7 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record DownloadFileFromBucketArguments( - string Bucket, - string Key, - string DestinationFilePathVariable); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record DownloadFileFromBucketArguments( + string Bucket, + string Key, + string DestinationFilePathVariable); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GenerateAudioWorkflowPathsArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GenerateAudioWorkflowPathsArguments.cs index 885fe7f..0786562 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GenerateAudioWorkflowPathsArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GenerateAudioWorkflowPathsArguments.cs @@ -1,6 +1,7 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record GenerateAudioWorkflowPathsArguments( - Guid TrackId, - string TemporaryRootDirectory, - string SourceKey); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record GenerateAudioWorkflowPathsArguments( + Guid TrackId, + string TemporaryRootDirectory, + string SourceKey); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GeneratePictureWorkflowPathsArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GeneratePictureWorkflowPathsArguments.cs index 65be2e6..9a71d63 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GeneratePictureWorkflowPathsArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GeneratePictureWorkflowPathsArguments.cs @@ -1,5 +1,6 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record GeneratePictureWorkflowPathsArguments( - string TemporaryRootDirectory, - string SourceKey); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record GeneratePictureWorkflowPathsArguments( + string TemporaryRootDirectory, + string SourceKey); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkLifeCycleArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkLifeCycleArguments.cs index 53bd438..f886919 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkLifeCycleArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkLifeCycleArguments.cs @@ -1,5 +1,6 @@ using Musify.Domain.ValueObjects; -namespace Musify.Infrastructure.MassTransit.Arguments; - -internal record MarkLifeCycleArguments(Guid Id, LifeCycleStatus Status); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + internal record MarkLifeCycleArguments(Guid Id, LifeCycleStatus Status); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs index 10cfa7a..b2efad7 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs @@ -1,9 +1,10 @@ using Musify.Application.Shared; -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record PublishAlbumPictureProcessingEventArguments( - Guid AlbumId, - string Bucket, - string FinalPictureKey, - ImageSizes Sizes); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record PublishAlbumPictureProcessingEventArguments( + Guid AlbumId, + string Bucket, + string FinalPictureKey, + ImageSizes Sizes); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs index b4831c0..8168dbf 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs @@ -1,9 +1,10 @@ using Musify.Application.Shared; -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record PublishPlayListPictureProcessingEventArguments( - Guid PlayListId, - string Bucket, - string FinalPictureKey, - ImageSizes Sizes); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record PublishPlayListPictureProcessingEventArguments( + Guid PlayListId, + string Bucket, + string FinalPictureKey, + ImageSizes Sizes); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs index 6bc151b..e711bc8 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs @@ -1,11 +1,12 @@ using Musify.Application.Shared; -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record PublishTrackProcessingEventsArguments( - Guid TrackId, - string Bucket, - string FinalPictureKey, - string FinalAudioKey, - string AudioDestinationFolderKey, - ImageSizes Sizes); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record PublishTrackProcessingEventsArguments( + Guid TrackId, + string Bucket, + string FinalPictureKey, + string FinalAudioKey, + string AudioDestinationFolderKey, + ImageSizes Sizes); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/RemoveFileFromBucketArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/RemoveFileFromBucketArguments.cs index ceb2c64..c0e803f 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/RemoveFileFromBucketArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/RemoveFileFromBucketArguments.cs @@ -1,5 +1,6 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record RemoveFileFromBucketArguments( - string Bucket, - string Key); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record RemoveFileFromBucketArguments( + string Bucket, + string Key); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/ResizePictureLocalArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/ResizePictureLocalArguments.cs index 04fca58..022fc7c 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/ResizePictureLocalArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/ResizePictureLocalArguments.cs @@ -1,7 +1,8 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record ResizePictureLocalArguments( - string SourceFilePathVariable, - string DestinationFilePathVariable, - int Width, - int Height); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record ResizePictureLocalArguments( + string SourceFilePathVariable, + string DestinationFilePathVariable, + int Width, + int Height); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TranscodeAudioArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TranscodeAudioArguments.cs index 9b33211..1533480 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TranscodeAudioArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TranscodeAudioArguments.cs @@ -1,6 +1,7 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record TranscodeAudioArguments( - Guid TrackId, - string SourceFilePathVariable, - string WorkingDirectoryVariable); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record TranscodeAudioArguments( + Guid TrackId, + string SourceFilePathVariable, + string WorkingDirectoryVariable); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TransferFilesToBucketArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TransferFilesToBucketArguments.cs index f1e34ae..dcc61fd 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TransferFilesToBucketArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TransferFilesToBucketArguments.cs @@ -1,6 +1,7 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record TransferFilesToBucketArguments( - string DestinationBucket, - string SourceDirectoryVariable, - string DestinationKey); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record TransferFilesToBucketArguments( + string DestinationBucket, + string SourceDirectoryVariable, + string DestinationKey); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePicturesArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePicturesArguments.cs index 18270ed..06e6115 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePicturesArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePicturesArguments.cs @@ -1,8 +1,9 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record UpdatePicturesArguments( - Guid SubjectId, - string OriginalPictureKey, - string SmallPictureVariable, - string MediumPictureVariable, - string LargePictureVariable); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record UpdatePicturesArguments( + Guid SubjectId, + string OriginalPictureKey, + string SmallPictureVariable, + string MediumPictureVariable, + string LargePictureVariable); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs index f9c46e0..b284c4e 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs @@ -1,6 +1,7 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record UpdateTrackAudioArguments( - Guid TrackId, - string AudioFolderKey, - string DurationSecondsVariable); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record UpdateTrackAudioArguments( + Guid TrackId, + string AudioFolderKey, + string DurationSecondsVariable); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UploadFileToBucketArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UploadFileToBucketArguments.cs index 66edbc2..a34ab21 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UploadFileToBucketArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UploadFileToBucketArguments.cs @@ -1,6 +1,7 @@ -namespace Musify.Infrastructure.MassTransit.Arguments; - -public record UploadFileToBucketArguments( - string FilePathVariable, - string DestinationBucket, - string DestinationRoute); +namespace Musify.Infrastructure.MassTransit.Arguments +{ + public record UploadFileToBucketArguments( + string FilePathVariable, + string DestinationBucket, + string DestinationRoute); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/AlbumProcessingFailedConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/AlbumProcessingFailedConsumer.cs index a8b46e2..f799916 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/AlbumProcessingFailedConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/AlbumProcessingFailedConsumer.cs @@ -3,16 +3,17 @@ using Musify.Application.Events; using Musify.Infrastructure.MassTransit.Activities.LifeCycle; using Musify.Infrastructure.MassTransit.RoutingSlip; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class AlbumProcessingFailedConsumer(IBus bus) : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public Task Consume(ConsumeContext context) + public class AlbumProcessingFailedConsumer(IBus bus) : IConsumer { - var message = context.Message; + public Task Consume(ConsumeContext context) + { + var message = context.Message; - return ProcessingFailedCleanup.ExecuteAsync( - bus, message.AlbumId, ActivityNames.MarkAlbumAsFailed, MarkAlbumLifeCycleActivity.ExecuteEndpointName, message.Bucket, - (ActivityNames.RemoveAlbumOriginalPicture, message.PictureKey)); + return ProcessingFailedCleanup.ExecuteAsync( + bus, message.AlbumId, ActivityNames.MarkAlbumAsFailed, MarkAlbumLifeCycleActivity.ExecuteEndpointName, message.Bucket, + (ActivityNames.RemoveAlbumOriginalPicture, message.PictureKey)); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs index 1b6e0c5..1c723ba 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs @@ -2,11 +2,12 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class CreateAlbumConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder) - : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public Task Consume(ConsumeContext context) => - bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + public class CreateAlbumConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs index 012df87..96b5585 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs @@ -2,11 +2,12 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class CreatePlayListConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder) - : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public Task Consume(ConsumeContext context) => - bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + public class CreatePlayListConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs index 9a7cb5e..6d46e90 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs @@ -2,11 +2,12 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class CreateTrackConsumer(IBus bus, CreateTrackRoutingSlipBuilder routingSlipBuilder) - : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public Task Consume(ConsumeContext context) => - bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + public class CreateTrackConsumer(IBus bus, CreateTrackRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteAlbumConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteAlbumConsumer.cs index 4802a43..42971c8 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteAlbumConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteAlbumConsumer.cs @@ -2,17 +2,18 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class DeleteAlbumConsumer(IBus bus, DeleteRoutingSlipBuilder routingSlipBuilder) - : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public async Task Consume(ConsumeContext context) + public class DeleteAlbumConsumer(IBus bus, DeleteRoutingSlipBuilder routingSlipBuilder) + : IConsumer { - var message = context.Message; - var routingSlip = await routingSlipBuilder.BuildAlbumAsync( - message.AlbumId, message.UserId, context.CorrelationId, context.CancellationToken); + public async Task Consume(ConsumeContext context) + { + var message = context.Message; + var routingSlip = await routingSlipBuilder.BuildAlbumAsync( + message.AlbumId, message.UserId, context.CorrelationId, context.CancellationToken); - await bus.Execute(routingSlip.Build()); + await bus.Execute(routingSlip.Build()); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs index 8175eba..35e8863 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs @@ -2,17 +2,18 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class DeletePlayListConsumer(IBus bus, DeleteRoutingSlipBuilder routingSlipBuilder) - : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public async Task Consume(ConsumeContext context) + public class DeletePlayListConsumer(IBus bus, DeleteRoutingSlipBuilder routingSlipBuilder) + : IConsumer { - var message = context.Message; - var routingSlip = await routingSlipBuilder.BuildPlayListAsync( - message.PlayListId, message.UserId, context.CorrelationId, context.CancellationToken); + public async Task Consume(ConsumeContext context) + { + var message = context.Message; + var routingSlip = await routingSlipBuilder.BuildPlayListAsync( + message.PlayListId, message.UserId, context.CorrelationId, context.CancellationToken); - await bus.Execute(routingSlip.Build()); + await bus.Execute(routingSlip.Build()); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs index 4d2b123..90a69be 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs @@ -2,17 +2,18 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class DeleteTrackConsumer(IBus bus, DeleteRoutingSlipBuilder routingSlipBuilder) - : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public async Task Consume(ConsumeContext context) + public class DeleteTrackConsumer(IBus bus, DeleteRoutingSlipBuilder routingSlipBuilder) + : IConsumer { - var message = context.Message; - var routingSlip = await routingSlipBuilder.BuildTrackAsync( - message.TrackId, message.UserId, context.CorrelationId, context.CancellationToken); + public async Task Consume(ConsumeContext context) + { + var message = context.Message; + var routingSlip = await routingSlipBuilder.BuildTrackAsync( + message.TrackId, message.UserId, context.CorrelationId, context.CancellationToken); - await bus.Execute(routingSlip.Build()); + await bus.Execute(routingSlip.Build()); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/PlayListProcessingFailedConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/PlayListProcessingFailedConsumer.cs index 78bcb62..0cdf562 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/PlayListProcessingFailedConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/PlayListProcessingFailedConsumer.cs @@ -3,16 +3,17 @@ using Musify.Application.Events; using Musify.Infrastructure.MassTransit.Activities.LifeCycle; using Musify.Infrastructure.MassTransit.RoutingSlip; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class PlayListProcessingFailedConsumer(IBus bus) : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public Task Consume(ConsumeContext context) + public class PlayListProcessingFailedConsumer(IBus bus) : IConsumer { - var message = context.Message; + public Task Consume(ConsumeContext context) + { + var message = context.Message; - return ProcessingFailedCleanup.ExecuteAsync( - bus, message.PlayListId, ActivityNames.MarkPlayListAsFailed, MarkPlayListLifeCycleActivity.ExecuteEndpointName, message.Bucket, - (ActivityNames.RemovePlayListOriginalPicture, message.PictureKey)); + return ProcessingFailedCleanup.ExecuteAsync( + bus, message.PlayListId, ActivityNames.MarkPlayListAsFailed, MarkPlayListLifeCycleActivity.ExecuteEndpointName, message.Bucket, + (ActivityNames.RemovePlayListOriginalPicture, message.PictureKey)); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingFailedCleanup.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingFailedCleanup.cs index 434ad9b..e717b55 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingFailedCleanup.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingFailedCleanup.cs @@ -3,32 +3,33 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.RoutingSlip; -namespace Musify.Infrastructure.MassTransit.Consumers; - -internal static class ProcessingFailedCleanup +namespace Musify.Infrastructure.MassTransit.Consumers { - /// Removes whatever files were left behind and marks the entity as failed. - public static Task ExecuteAsync( - IBus bus, - Guid subjectId, - string markStepName, - string markEndpointName, - string? bucket, - params (string StepName, string? Key)[] files) + internal static class ProcessingFailedCleanup { - var builder = RoutingSlips.Create(correlationId: null); - - if (!string.IsNullOrWhiteSpace(bucket)) + /// Removes whatever files were left behind and marks the entity as failed. + public static Task ExecuteAsync( + IBus bus, + Guid subjectId, + string markStepName, + string markEndpointName, + string? bucket, + params (string StepName, string? Key)[] files) { - foreach (var (stepName, key) in files) + var builder = RoutingSlips.Create(correlationId: null); + + if (!string.IsNullOrWhiteSpace(bucket)) { - if (!string.IsNullOrWhiteSpace(key)) - builder.AddRemoveFile(stepName, bucket, key); + foreach (var (stepName, key) in files) + { + if (!string.IsNullOrWhiteSpace(key)) + builder.AddRemoveFile(stepName, bucket, key); + } } + + builder.AddStep(markStepName, markEndpointName, new MarkLifeCycleArguments(subjectId, LifeCycleStatus.Failed)); + + return bus.Execute(builder.Build()); } - - builder.AddStep(markStepName, markEndpointName, new MarkLifeCycleArguments(subjectId, LifeCycleStatus.Failed)); - - return bus.Execute(builder.Build()); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs index c41dd54..2ffe99c 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs @@ -3,58 +3,59 @@ using MassTransit.Courier.Contracts; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class ProcessingSlipFaultConsumer : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public const string QueueName = "processing-slip-fault"; - - public async Task Consume(ConsumeContext context) + public class ProcessingSlipFaultConsumer : IConsumer { - var variables = context.Message.Variables; + public const string QueueName = "processing-slip-fault"; - if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.ProcessKind, out var kindObject)) - return; - - if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.SubjectId, out var subjectObject) - || !Guid.TryParse(subjectObject?.ToString(), out var subjectId)) + public async Task Consume(ConsumeContext context) { - return; + var variables = context.Message.Variables; + + if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.ProcessKind, out var kindObject)) + return; + + if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.SubjectId, out var subjectObject) + || !Guid.TryParse(subjectObject?.ToString(), out var subjectId)) + { + return; + } + + switch (kindObject?.ToString()) + { + case RoutingSlipVariableNames.ProcessKinds.TrackPicture: + await context.Publish(new TrackPictureProcessingFailed(subjectId)); + break; + case RoutingSlipVariableNames.ProcessKinds.TrackAudio: + await context.Publish(new TrackAudioProcessingFailed(subjectId)); + break; + case RoutingSlipVariableNames.ProcessKinds.PlayListPicture: + await context.Publish(new PlayListPictureProcessingFailed(subjectId)); + break; + case RoutingSlipVariableNames.ProcessKinds.AlbumPicture: + await context.Publish(new AlbumPictureProcessingFailed(subjectId)); + break; + case RoutingSlipVariableNames.ProcessKinds.TrackCreation: + await context.Publish(new TrackProcessingFailed( + subjectId, Variable(variables, RoutingSlipVariableNames.Workflow.Bucket), + Variable(variables, RoutingSlipVariableNames.Workflow.PictureKey), + Variable(variables, RoutingSlipVariableNames.Workflow.AudioKey))); + break; + case RoutingSlipVariableNames.ProcessKinds.AlbumCreation: + await context.Publish(new AlbumProcessingFailed( + subjectId, Variable(variables, RoutingSlipVariableNames.Workflow.Bucket), + Variable(variables, RoutingSlipVariableNames.Workflow.PictureKey))); + break; + case RoutingSlipVariableNames.ProcessKinds.PlayListCreation: + await context.Publish(new PlayListProcessingFailed( + subjectId, Variable(variables, RoutingSlipVariableNames.Workflow.Bucket), + Variable(variables, RoutingSlipVariableNames.Workflow.PictureKey))); + break; + } } - switch (kindObject?.ToString()) - { - case RoutingSlipVariableNames.ProcessKinds.TrackPicture: - await context.Publish(new TrackPictureProcessingFailed(subjectId)); - break; - case RoutingSlipVariableNames.ProcessKinds.TrackAudio: - await context.Publish(new TrackAudioProcessingFailed(subjectId)); - break; - case RoutingSlipVariableNames.ProcessKinds.PlayListPicture: - await context.Publish(new PlayListPictureProcessingFailed(subjectId)); - break; - case RoutingSlipVariableNames.ProcessKinds.AlbumPicture: - await context.Publish(new AlbumPictureProcessingFailed(subjectId)); - break; - case RoutingSlipVariableNames.ProcessKinds.TrackCreation: - await context.Publish(new TrackProcessingFailed( - subjectId, Variable(variables, RoutingSlipVariableNames.Workflow.Bucket), - Variable(variables, RoutingSlipVariableNames.Workflow.PictureKey), - Variable(variables, RoutingSlipVariableNames.Workflow.AudioKey))); - break; - case RoutingSlipVariableNames.ProcessKinds.AlbumCreation: - await context.Publish(new AlbumProcessingFailed( - subjectId, Variable(variables, RoutingSlipVariableNames.Workflow.Bucket), - Variable(variables, RoutingSlipVariableNames.Workflow.PictureKey))); - break; - case RoutingSlipVariableNames.ProcessKinds.PlayListCreation: - await context.Publish(new PlayListProcessingFailed( - subjectId, Variable(variables, RoutingSlipVariableNames.Workflow.Bucket), - Variable(variables, RoutingSlipVariableNames.Workflow.PictureKey))); - break; - } + private static string? Variable(IDictionary variables, string name) => + variables.TryGetValue(name, out var value) ? value?.ToString() : null; } - - private static string? Variable(IDictionary variables, string name) => - variables.TryGetValue(name, out var value) ? value?.ToString() : null; } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs index 0239bd4..6d253ef 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs @@ -3,41 +3,42 @@ using MassTransit.Courier.Contracts; using Microsoft.Extensions.Logging; using Musify.Infrastructure.MassTransit.RoutingSlip; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class RoutingSlipCleanUpConsumer( - ILogger logger) : - IConsumer, - IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public const string QueueName = "routing-slip-clean-up"; - - public Task Consume(ConsumeContext context) - => DeleteFolder(context.Message.Variables); - - public Task Consume(ConsumeContext context) - => DeleteFolder(context.Message.Variables); - - private Task DeleteFolder(IDictionary variables) + public class RoutingSlipCleanUpConsumer( + ILogger logger) : + IConsumer, + IConsumer { - if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.TemporalDirectory, out var pathObject)) - return Task.CompletedTask; + public const string QueueName = "routing-slip-clean-up"; - var pathString = pathObject.ToString(); + public Task Consume(ConsumeContext context) + => DeleteFolder(context.Message.Variables); - if (string.IsNullOrEmpty(pathString)) - return Task.CompletedTask; + public Task Consume(ConsumeContext context) + => DeleteFolder(context.Message.Variables); - try + private Task DeleteFolder(IDictionary variables) { - Directory.Delete(pathString, recursive: true); - logger.LogInformation("Deleted temporal directory at path {Path}", pathString); - } - catch (DirectoryNotFoundException) - { - logger.LogWarning("Temporal directory at path {Path} was not found for deletion", pathString); - } + if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.TemporalDirectory, out var pathObject)) + return Task.CompletedTask; - return Task.CompletedTask; + var pathString = pathObject.ToString(); + + if (string.IsNullOrEmpty(pathString)) + return Task.CompletedTask; + + try + { + Directory.Delete(pathString, recursive: true); + logger.LogInformation("Deleted temporal directory at path {Path}", pathString); + } + catch (DirectoryNotFoundException) + { + logger.LogWarning("Temporal directory at path {Path} was not found for deletion", pathString); + } + + return Task.CompletedTask; + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/TrackProcessingFailedConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/TrackProcessingFailedConsumer.cs index c2f5a3b..83852ef 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/TrackProcessingFailedConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/TrackProcessingFailedConsumer.cs @@ -3,17 +3,18 @@ using Musify.Application.Events; using Musify.Infrastructure.MassTransit.Activities.LifeCycle; using Musify.Infrastructure.MassTransit.RoutingSlip; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class TrackProcessingFailedConsumer(IBus bus) : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public Task Consume(ConsumeContext context) + public class TrackProcessingFailedConsumer(IBus bus) : IConsumer { - var message = context.Message; + public Task Consume(ConsumeContext context) + { + var message = context.Message; - return ProcessingFailedCleanup.ExecuteAsync( - bus, message.TrackId, ActivityNames.MarkTrackAsFailed, MarkTrackLifeCycleActivity.ExecuteEndpointName, message.Bucket, - (ActivityNames.RemoveTrackOriginalPicture, message.PictureKey), - (ActivityNames.RemoveTrackOriginalAudio, message.AudioKey)); + return ProcessingFailedCleanup.ExecuteAsync( + bus, message.TrackId, ActivityNames.MarkTrackAsFailed, MarkTrackLifeCycleActivity.ExecuteEndpointName, message.Bucket, + (ActivityNames.RemoveTrackOriginalPicture, message.PictureKey), + (ActivityNames.RemoveTrackOriginalAudio, message.AudioKey)); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs index ad9f8f3..caeb800 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs @@ -2,11 +2,12 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class UpdateAlbumPictureConsumer(IBus bus, PictureWorkflowRoutingSlipBuilder routingSlipBuilder) - : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public Task Consume(ConsumeContext context) => - bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + public class UpdateAlbumPictureConsumer(IBus bus, PictureWorkflowRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs index 4069246..ec1203f 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs @@ -2,11 +2,12 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class UpdateAlbumPictureSourceConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder) - : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public Task Consume(ConsumeContext context) => - bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + public class UpdateAlbumPictureSourceConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs index d89df5e..079493c 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs @@ -2,11 +2,12 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class UpdatePlayListPictureConsumer(IBus bus, PictureWorkflowRoutingSlipBuilder routingSlipBuilder) - : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public Task Consume(ConsumeContext context) => - bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + public class UpdatePlayListPictureConsumer(IBus bus, PictureWorkflowRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs index 8082d79..177685a 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs @@ -2,11 +2,12 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class UpdatePlayListPictureSourceConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder) - : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public Task Consume(ConsumeContext context) => - bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + public class UpdatePlayListPictureSourceConsumer(IBus bus, PictureSourceRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs index 221dc24..5570cec 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs @@ -2,11 +2,12 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class UpdateTrackAudioConsumer(IBus bus, AudioWorkflowRoutingSlipBuilder routingSlipBuilder) - : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public Task Consume(ConsumeContext context) => - bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + public class UpdateTrackAudioConsumer(IBus bus, AudioWorkflowRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs index d26de5a..079c8e2 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs @@ -2,11 +2,12 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers; - -public class UpdateTrackPictureConsumer(IBus bus, PictureWorkflowRoutingSlipBuilder routingSlipBuilder) - : IConsumer +namespace Musify.Infrastructure.MassTransit.Consumers { - public Task Consume(ConsumeContext context) => - bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + public class UpdateTrackPictureConsumer(IBus bus, PictureWorkflowRoutingSlipBuilder routingSlipBuilder) + : IConsumer + { + public Task Consume(ConsumeContext context) => + bus.Execute(routingSlipBuilder.Build(context.Message, context.CorrelationId).Build()); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs index 0053b0d..9561290 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs @@ -13,62 +13,63 @@ using Musify.Infrastructure.MassTransit.Consumers; using Musify.Infrastructure.MassTransit.Logs; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit; - -public static partial class MassTransitDependencyInjection +namespace Musify.Infrastructure.MassTransit { - private static void RegisterRoutingSlipBuilders(IServiceCollection services) + public static partial class MassTransitDependencyInjection { - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - } + private static void RegisterRoutingSlipBuilders(IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } - private static void RegisterConsumersAndActivities(IBusRegistrationConfigurator options) - { - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); - options.AddConsumer(); + private static void RegisterConsumersAndActivities(IBusRegistrationConfigurator options) + { + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); + options.AddConsumer(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); - options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); + options.AddExecuteActivity(); - options.AddActivity(); - options.AddActivity(); - options.AddActivity(); - options.AddActivity(); - options.AddActivity(); - options.AddActivity(); - options.AddActivity(); - options.AddActivity(); - options.AddActivity(); - options.AddActivity(); - options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + options.AddActivity(); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs index 3ddf79f..24940fb 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs @@ -7,107 +7,108 @@ using Musify.Infrastructure.Configuration; using Musify.Infrastructure.MassTransit.Sagas; using Musify.Infrastructure.Persistence; -namespace Musify.Infrastructure.MassTransit; - -public static partial class MassTransitDependencyInjection +namespace Musify.Infrastructure.MassTransit { - public static IServiceCollection AddMassTransitClient(this IServiceCollection services, IConfiguration configuration) + public static partial class MassTransitDependencyInjection { - services.AddValidatedOptions(configuration); - services.AddScoped(); - - services.AddMassTransit(options => + public static IServiceCollection AddMassTransitClient(this IServiceCollection services, IConfiguration configuration) { - AddOutbox(options); + services.AddValidatedOptions(configuration); + services.AddScoped(); - options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) => - ConfigureRabbitMqHost( - busFactoryConfigurator, - busRegistrationContext.GetRequiredService())); - }); - - return services; - } - - public static IServiceCollection AddMassTransitConsumers(this IServiceCollection services, IConfiguration configuration) - { - services.AddValidatedOptions(configuration); - - services.AddValidatedOptions(configuration); - services.AddScoped(); - - RegisterRoutingSlipBuilders(services); - - services.AddMassTransit(options => - { - AddOutbox(options); - - options.SetKebabCaseEndpointNameFormatter(); - - RegisterConsumersAndActivities(options); - - options.AddSagaStateMachine() - .EntityFrameworkRepository(ConfigureSagaRepository); - - options.AddSagaStateMachine() - .EntityFrameworkRepository(ConfigureSagaRepository); - - options.AddSagaStateMachine() - .EntityFrameworkRepository(ConfigureSagaRepository); - - options.AddConfigureEndpointsCallback((registrationContext, _, endpointConfigurator) => + services.AddMassTransit(options => { - UseStandardRetry(endpointConfigurator); - endpointConfigurator.UseEntityFrameworkOutbox(registrationContext); + AddOutbox(options); + + options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) => + ConfigureRabbitMqHost( + busFactoryConfigurator, + busRegistrationContext.GetRequiredService())); }); - options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) => + return services; + } + + public static IServiceCollection AddMassTransitConsumers(this IServiceCollection services, IConfiguration configuration) + { + services.AddValidatedOptions(configuration); + + services.AddValidatedOptions(configuration); + services.AddScoped(); + + RegisterRoutingSlipBuilders(services); + + services.AddMassTransit(options => { - ConfigureRabbitMqHost( - busFactoryConfigurator, - busRegistrationContext.GetRequiredService()); + AddOutbox(options); - busFactoryConfigurator.ConfigureEndpoints(busRegistrationContext); + options.SetKebabCaseEndpointNameFormatter(); + + RegisterConsumersAndActivities(options); + + options.AddSagaStateMachine() + .EntityFrameworkRepository(ConfigureSagaRepository); + + options.AddSagaStateMachine() + .EntityFrameworkRepository(ConfigureSagaRepository); + + options.AddSagaStateMachine() + .EntityFrameworkRepository(ConfigureSagaRepository); + + options.AddConfigureEndpointsCallback((registrationContext, _, endpointConfigurator) => + { + UseStandardRetry(endpointConfigurator); + endpointConfigurator.UseEntityFrameworkOutbox(registrationContext); + }); + + options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) => + { + ConfigureRabbitMqHost( + busFactoryConfigurator, + busRegistrationContext.GetRequiredService()); + + busFactoryConfigurator.ConfigureEndpoints(busRegistrationContext); + }); }); - }); - return services; - } + return services; + } - private static void AddOutbox(IBusRegistrationConfigurator options) => - options.AddEntityFrameworkOutbox(outbox => + private static void AddOutbox(IBusRegistrationConfigurator options) => + options.AddEntityFrameworkOutbox(outbox => + { + outbox.UsePostgres(); + outbox.UseBusOutbox(); + + outbox.QueryDelay = TimeSpan.FromSeconds(1); + outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30); + }); + + private static void ConfigureSagaRepository(IEntityFrameworkSagaRepositoryConfigurator repository) + where TSaga : class, ISaga { - outbox.UsePostgres(); - outbox.UseBusOutbox(); + repository.ConcurrencyMode = ConcurrencyMode.Pessimistic; + repository.ExistingDbContext(); + repository.UsePostgres(); + } - outbox.QueryDelay = TimeSpan.FromSeconds(1); - outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30); - }); + private static void UseStandardRetry(IReceiveEndpointConfigurator endpointConfigurator) => + endpointConfigurator.UseMessageRetry(retryConfigurator => + retryConfigurator.Exponential( + retryLimit: 5, + minInterval: TimeSpan.FromSeconds(1), + maxInterval: TimeSpan.FromSeconds(30), + intervalDelta: TimeSpan.FromSeconds(2))); - private static void ConfigureSagaRepository(IEntityFrameworkSagaRepositoryConfigurator repository) - where TSaga : class, ISaga - { - repository.ConcurrencyMode = ConcurrencyMode.Pessimistic; - repository.ExistingDbContext(); - repository.UsePostgres(); - } - - private static void UseStandardRetry(IReceiveEndpointConfigurator endpointConfigurator) => - endpointConfigurator.UseMessageRetry(retryConfigurator => - retryConfigurator.Exponential( - retryLimit: 5, - minInterval: TimeSpan.FromSeconds(1), - maxInterval: TimeSpan.FromSeconds(30), - intervalDelta: TimeSpan.FromSeconds(2))); - - private static void ConfigureRabbitMqHost( - IRabbitMqBusFactoryConfigurator busFactoryConfigurator, - MassTransitConfiguration massTransitConfiguration) - { - busFactoryConfigurator.Host(massTransitConfiguration.Address, options => + private static void ConfigureRabbitMqHost( + IRabbitMqBusFactoryConfigurator busFactoryConfigurator, + MassTransitConfiguration massTransitConfiguration) { - options.Username(massTransitConfiguration.Username); - options.Password(massTransitConfiguration.Password); - }); + busFactoryConfigurator.Host(massTransitConfiguration.Address, options => + { + options.Username(massTransitConfiguration.Username); + options.Password(massTransitConfiguration.Password); + }); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/ConsumeUploadIntentsLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/ConsumeUploadIntentsLog.cs index bce11e7..275ddd5 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/ConsumeUploadIntentsLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/ConsumeUploadIntentsLog.cs @@ -1,4 +1,5 @@ -namespace Musify.Infrastructure.MassTransit.Logs; - -public record ConsumeUploadIntentsLog( - Guid[] IntentIds); +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record ConsumeUploadIntentsLog( + Guid[] IntentIds); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/CopyFileInBucketLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/CopyFileInBucketLog.cs index 0ee7c18..609258a 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/CopyFileInBucketLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/CopyFileInBucketLog.cs @@ -1,5 +1,6 @@ -namespace Musify.Infrastructure.MassTransit.Logs; - -public record CopyFileInBucketLog( - string DestinationBucket, - string DestinationKey); +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record CopyFileInBucketLog( + string DestinationBucket, + string DestinationKey); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromBucketLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromBucketLog.cs index 84769ee..0ecef2b 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromBucketLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromBucketLog.cs @@ -1,6 +1,7 @@ -namespace Musify.Infrastructure.MassTransit.Logs; - -public record DownloadFileFromBucketLog( - string Bucket, - string Key, - string DestinationFilePath); +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record DownloadFileFromBucketLog( + string Bucket, + string Key, + string DestinationFilePath); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/GeneratePictureWorkflowPathsLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/GeneratePictureWorkflowPathsLog.cs index ed10990..9a9c223 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/GeneratePictureWorkflowPathsLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/GeneratePictureWorkflowPathsLog.cs @@ -1,3 +1,4 @@ -namespace Musify.Infrastructure.MassTransit.Logs; - -public record GeneratePictureWorkflowPathsLog(string WorkingDirectory); +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record GeneratePictureWorkflowPathsLog(string WorkingDirectory); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/ResizePictureLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/ResizePictureLog.cs index 32349fb..f255d51 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/ResizePictureLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/ResizePictureLog.cs @@ -1,3 +1,4 @@ -namespace Musify.Infrastructure.MassTransit.Logs; - -public record ResizePictureLog(string DestinationFilePath); +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record ResizePictureLog(string DestinationFilePath); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/TranscodeAudioLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/TranscodeAudioLog.cs index 7bcf413..c85933a 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/TranscodeAudioLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/TranscodeAudioLog.cs @@ -1,3 +1,4 @@ -namespace Musify.Infrastructure.MassTransit.Logs; - -public record TranscodeAudioLog(string WorkingDirectory); +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record TranscodeAudioLog(string WorkingDirectory); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/TransferFilesToBucketLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/TransferFilesToBucketLog.cs index ba61c9a..5db2002 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/TransferFilesToBucketLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/TransferFilesToBucketLog.cs @@ -1,3 +1,4 @@ -namespace Musify.Infrastructure.MassTransit.Logs; - -public record TransferFilesToBucketLog(string DestinationBucket, string[] UploadedKeys); +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record TransferFilesToBucketLog(string DestinationBucket, string[] UploadedKeys); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePicturesLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePicturesLog.cs index d17297d..55f1fcb 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePicturesLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePicturesLog.cs @@ -1,8 +1,9 @@ -namespace Musify.Infrastructure.MassTransit.Logs; - -public record UpdatePicturesLog( - Guid SubjectId, - string? PreviousOriginalName, - string? PreviousSmallName, - string? PreviousMediumName, - string? PreviousLargeName); +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record UpdatePicturesLog( + Guid SubjectId, + string? PreviousOriginalName, + string? PreviousSmallName, + string? PreviousMediumName, + string? PreviousLargeName); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs index e883935..52064ab 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs @@ -1,6 +1,7 @@ -namespace Musify.Infrastructure.MassTransit.Logs; - -public record UpdateTrackAudioLog( - Guid TrackId, - string? PreviousAudioFolderName, - double PreviousDurationSeconds); +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record UpdateTrackAudioLog( + Guid TrackId, + string? PreviousAudioFolderName, + double PreviousDurationSeconds); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UploadFileToBucketLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UploadFileToBucketLog.cs index 7cf63ff..ab95439 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UploadFileToBucketLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UploadFileToBucketLog.cs @@ -1,3 +1,4 @@ -namespace Musify.Infrastructure.MassTransit.Logs; - -public record UploadFileToBucketLog(string DestinationBucket, string DestinationKey); +namespace Musify.Infrastructure.MassTransit.Logs +{ + public record UploadFileToBucketLog(string DestinationBucket, string DestinationKey); +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs b/backend/Core/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs index a10b3d4..e9d300f 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs @@ -1,11 +1,12 @@ using MassTransit; using Musify.Application.Contracts; -namespace Musify.Infrastructure.MassTransit; - -public class MassTransitEventBus(IPublishEndpoint publishEndpoint) - : IEventBus +namespace Musify.Infrastructure.MassTransit { - public Task PublishAsync(T message, CancellationToken cancellationToken) where T : class => - publishEndpoint.Publish(message, cancellationToken); + public class MassTransitEventBus(IPublishEndpoint publishEndpoint) + : IEventBus + { + public Task PublishAsync(T message, CancellationToken cancellationToken) where T : class => + publishEndpoint.Publish(message, cancellationToken); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs index c6ddefd..25cbd26 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs @@ -1,62 +1,63 @@ -namespace Musify.Infrastructure.MassTransit.RoutingSlip; - -internal static class ActivityNames +namespace Musify.Infrastructure.MassTransit.RoutingSlip { - internal const string MarkTrackAsRemoving = "MarkTrackAsRemoving"; - internal const string MarkTrackAsFailed = "MarkTrackAsFailed"; - internal const string DeleteTrackFromDb = "DeleteTrackFromDb"; - internal const string MarkPlayListAsRemoving = "MarkPlayListAsRemoving"; - internal const string MarkPlayListAsFailed = "MarkPlayListAsFailed"; - internal const string DeletePlayListFromDb = "DeletePlayListFromDb"; - internal const string MarkAlbumAsFailed = "MarkAlbumAsFailed"; - internal const string MarkAlbumAsRemoving = "MarkAlbumAsRemoving"; - internal const string DeleteAlbumFromDb = "DeleteAlbumFromDb"; + internal static class ActivityNames + { + internal const string MarkTrackAsRemoving = "MarkTrackAsRemoving"; + internal const string MarkTrackAsFailed = "MarkTrackAsFailed"; + internal const string DeleteTrackFromDb = "DeleteTrackFromDb"; + internal const string MarkPlayListAsRemoving = "MarkPlayListAsRemoving"; + internal const string MarkPlayListAsFailed = "MarkPlayListAsFailed"; + internal const string DeletePlayListFromDb = "DeletePlayListFromDb"; + internal const string MarkAlbumAsFailed = "MarkAlbumAsFailed"; + internal const string MarkAlbumAsRemoving = "MarkAlbumAsRemoving"; + internal const string DeleteAlbumFromDb = "DeleteAlbumFromDb"; - internal const string GenerateAudioWorkflowPaths = "GenerateAudioWorkflowPaths"; - internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths"; + internal const string GenerateAudioWorkflowPaths = "GenerateAudioWorkflowPaths"; + internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths"; - internal const string DownloadFile = "DownloadFile"; + internal const string DownloadFile = "DownloadFile"; - internal const string TranscodeAudio = "TranscodeAudio"; + internal const string TranscodeAudio = "TranscodeAudio"; - internal const string ResizeSmall = "ResizeSmall"; - internal const string ResizeMedium = "ResizeMedium"; - internal const string ResizeLarge = "ResizeLarge"; + internal const string ResizeSmall = "ResizeSmall"; + internal const string ResizeMedium = "ResizeMedium"; + internal const string ResizeLarge = "ResizeLarge"; - internal const string UploadSmall = "UploadSmall"; - internal const string UploadMedium = "UploadMedium"; - internal const string UploadLarge = "UploadLarge"; + internal const string UploadSmall = "UploadSmall"; + internal const string UploadMedium = "UploadMedium"; + internal const string UploadLarge = "UploadLarge"; - internal const string TransferFiles = "TransferFiles"; + internal const string TransferFiles = "TransferFiles"; - internal const string UpdateTrackPicture = "UpdateTrackPicture"; - internal const string UpdatePlayListPicture = "UpdatePlayListPicture"; - internal const string UpdateAlbumPicture = "UpdateAlbumPicture"; - internal const string UpdateTrackAudio = "UpdateTrackAudio"; + internal const string UpdateTrackPicture = "UpdateTrackPicture"; + internal const string UpdatePlayListPicture = "UpdatePlayListPicture"; + internal const string UpdateAlbumPicture = "UpdateAlbumPicture"; + internal const string UpdateTrackAudio = "UpdateTrackAudio"; - internal const string CopyPictureToFinal = "CopyPictureToFinal"; - internal const string CopyAudioToFinal = "CopyAudioToFinal"; + internal const string CopyPictureToFinal = "CopyPictureToFinal"; + internal const string CopyAudioToFinal = "CopyAudioToFinal"; - internal const string ConsumeUploadIntents = "ConsumeUploadIntents"; + internal const string ConsumeUploadIntents = "ConsumeUploadIntents"; - internal const string PublishTrackProcessingEvents = "PublishTrackProcessingEvents"; - internal const string PublishPlayListPictureProcessingEvent = "PublishPlayListPictureProcessingEvent"; - internal const string PublishAlbumPictureProcessingEvent = "PublishAlbumPictureProcessingEvent"; + internal const string PublishTrackProcessingEvents = "PublishTrackProcessingEvents"; + internal const string PublishPlayListPictureProcessingEvent = "PublishPlayListPictureProcessingEvent"; + internal const string PublishAlbumPictureProcessingEvent = "PublishAlbumPictureProcessingEvent"; - internal const string RemoveTrackOriginalPicture = "RemoveTrackOriginalPicture"; - internal const string RemoveTrackSmallPicture = "RemoveTrackSmallPicture"; - internal const string RemoveTrackMediumPicture = "RemoveTrackMediumPicture"; - internal const string RemoveTrackLargePicture = "RemoveTrackLargePicture"; - internal const string RemoveTrackOriginalAudio = "RemoveTrackOriginalAudio"; - internal const string RemoveTrackProcessedAudio = "RemoveTrackProcessedAudio"; + internal const string RemoveTrackOriginalPicture = "RemoveTrackOriginalPicture"; + internal const string RemoveTrackSmallPicture = "RemoveTrackSmallPicture"; + internal const string RemoveTrackMediumPicture = "RemoveTrackMediumPicture"; + internal const string RemoveTrackLargePicture = "RemoveTrackLargePicture"; + internal const string RemoveTrackOriginalAudio = "RemoveTrackOriginalAudio"; + internal const string RemoveTrackProcessedAudio = "RemoveTrackProcessedAudio"; - internal const string RemovePlayListOriginalPicture = "RemovePlayListOriginalPicture"; - internal const string RemovePlayListSmallPicture = "RemovePlayListSmallPicture"; - internal const string RemovePlayListMediumPicture = "RemovePlayListMediumPicture"; - internal const string RemovePlayListLargePicture = "RemovePlayListLargePicture"; + internal const string RemovePlayListOriginalPicture = "RemovePlayListOriginalPicture"; + internal const string RemovePlayListSmallPicture = "RemovePlayListSmallPicture"; + internal const string RemovePlayListMediumPicture = "RemovePlayListMediumPicture"; + internal const string RemovePlayListLargePicture = "RemovePlayListLargePicture"; - internal const string RemoveAlbumOriginalPicture = "RemoveAlbumOriginalPicture"; - internal const string RemoveAlbumSmallPicture = "RemoveAlbumSmallPicture"; - internal const string RemoveAlbumMediumPicture = "RemoveAlbumMediumPicture"; - internal const string RemoveAlbumLargePicture = "RemoveAlbumLargePicture"; + internal const string RemoveAlbumOriginalPicture = "RemoveAlbumOriginalPicture"; + internal const string RemoveAlbumSmallPicture = "RemoveAlbumSmallPicture"; + internal const string RemoveAlbumMediumPicture = "RemoveAlbumMediumPicture"; + internal const string RemoveAlbumLargePicture = "RemoveAlbumLargePicture"; + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs index b3f1e07..d4e3be2 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs @@ -5,40 +5,41 @@ using Musify.Infrastructure.MassTransit.Activities.Audio; using Musify.Infrastructure.MassTransit.Activities.Files; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; - -public class AudioWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration) +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders { - public RoutingSlipBuilder Build(UpdateTrackAudioEvent message, Guid? correlationId) => - RoutingSlips.Create(correlationId) - .AddStep( - ActivityNames.GenerateAudioWorkflowPaths, - GenerateAudioWorkflowPathsActivity.ExecuteEndpointName, - new GenerateAudioWorkflowPathsArguments( - message.TrackId, workerConfiguration.Routes.TemporaryFilesDirectory, message.SourceKey)) - .AddStep( - ActivityNames.DownloadFile, - DownloadFileFromBucketActivity.ExecuteEndpointName, - new DownloadFileFromBucketArguments( - message.SourceBucket, message.SourceKey, RoutingSlipVariableNames.Audio.SourceFilePath)) - .AddStep( - ActivityNames.TranscodeAudio, - TranscodeAudioActivity.ExecuteEndpointName, - new TranscodeAudioArguments( - message.TrackId, - RoutingSlipVariableNames.Audio.SourceFilePath, - RoutingSlipVariableNames.Workflow.TemporalDirectory)) - .AddStep( - ActivityNames.TransferFiles, - TransferFilesToBucketActivity.ExecuteEndpointName, - new TransferFilesToBucketArguments( - message.DestinationBucket, - RoutingSlipVariableNames.Audio.TranscodedDirectory, - message.DestinationFolderKey)) - .AddStep( - ActivityNames.UpdateTrackAudio, - UpdateTrackAudioActivity.ExecuteEndpointName, - new UpdateTrackAudioArguments( - message.TrackId, message.DestinationFolderKey, RoutingSlipVariableNames.Audio.DurationSeconds)) - .TrackFaults(message.TrackId, RoutingSlipVariableNames.ProcessKinds.TrackAudio); + public class AudioWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration) + { + public RoutingSlipBuilder Build(UpdateTrackAudioEvent message, Guid? correlationId) => + RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.GenerateAudioWorkflowPaths, + GenerateAudioWorkflowPathsActivity.ExecuteEndpointName, + new GenerateAudioWorkflowPathsArguments( + message.TrackId, workerConfiguration.Routes.TemporaryFilesDirectory, message.SourceKey)) + .AddStep( + ActivityNames.DownloadFile, + DownloadFileFromBucketActivity.ExecuteEndpointName, + new DownloadFileFromBucketArguments( + message.SourceBucket, message.SourceKey, RoutingSlipVariableNames.Audio.SourceFilePath)) + .AddStep( + ActivityNames.TranscodeAudio, + TranscodeAudioActivity.ExecuteEndpointName, + new TranscodeAudioArguments( + message.TrackId, + RoutingSlipVariableNames.Audio.SourceFilePath, + RoutingSlipVariableNames.Workflow.TemporalDirectory)) + .AddStep( + ActivityNames.TransferFiles, + TransferFilesToBucketActivity.ExecuteEndpointName, + new TransferFilesToBucketArguments( + message.DestinationBucket, + RoutingSlipVariableNames.Audio.TranscodedDirectory, + message.DestinationFolderKey)) + .AddStep( + ActivityNames.UpdateTrackAudio, + UpdateTrackAudioActivity.ExecuteEndpointName, + new UpdateTrackAudioArguments( + message.TrackId, message.DestinationFolderKey, RoutingSlipVariableNames.Audio.DurationSeconds)) + .TrackFaults(message.TrackId, RoutingSlipVariableNames.ProcessKinds.TrackAudio); + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs index d8b1f50..f8ba5ab 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs @@ -5,41 +5,42 @@ using Musify.Infrastructure.MassTransit.Activities.Tracks; using Musify.Infrastructure.MassTransit.Activities.UploadIntents; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; - -public class CreateTrackRoutingSlipBuilder +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders { - public RoutingSlipBuilder Build(CreateTrackResourcesEvent message, Guid? correlationId) + public class CreateTrackRoutingSlipBuilder { - var builder = RoutingSlips.Create(correlationId) - .AddStep( - ActivityNames.CopyPictureToFinal, - CopyFileInBucketActivity.ExecuteEndpointName, - new CopyFileInBucketArguments(message.Bucket, message.PictureSourceKey, message.Bucket, message.PictureDestinationKey)) - .AddStep( - ActivityNames.CopyAudioToFinal, - CopyFileInBucketActivity.ExecuteEndpointName, - new CopyFileInBucketArguments(message.Bucket, message.AudioSourceKey, message.Bucket, message.AudioDestinationKey)) - .AddStep( - ActivityNames.ConsumeUploadIntents, - ConsumeUploadIntentsActivity.ExecuteEndpointName, - new ConsumeUploadIntentsArguments([message.PictureIntentId, message.AudioIntentId])) - .AddStep( - ActivityNames.PublishTrackProcessingEvents, - PublishTrackProcessingEventsActivity.ExecuteEndpointName, - new PublishTrackProcessingEventsArguments( - message.TrackId, - message.Bucket, - message.PictureDestinationKey, - message.AudioDestinationKey, - message.AudioProcessedFolderKey, - message.Sizes)) - .TrackFaults(message.TrackId, RoutingSlipVariableNames.ProcessKinds.TrackCreation); + public RoutingSlipBuilder Build(CreateTrackResourcesEvent message, Guid? correlationId) + { + var builder = RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.CopyPictureToFinal, + CopyFileInBucketActivity.ExecuteEndpointName, + new CopyFileInBucketArguments(message.Bucket, message.PictureSourceKey, message.Bucket, message.PictureDestinationKey)) + .AddStep( + ActivityNames.CopyAudioToFinal, + CopyFileInBucketActivity.ExecuteEndpointName, + new CopyFileInBucketArguments(message.Bucket, message.AudioSourceKey, message.Bucket, message.AudioDestinationKey)) + .AddStep( + ActivityNames.ConsumeUploadIntents, + ConsumeUploadIntentsActivity.ExecuteEndpointName, + new ConsumeUploadIntentsArguments([message.PictureIntentId, message.AudioIntentId])) + .AddStep( + ActivityNames.PublishTrackProcessingEvents, + PublishTrackProcessingEventsActivity.ExecuteEndpointName, + new PublishTrackProcessingEventsArguments( + message.TrackId, + message.Bucket, + message.PictureDestinationKey, + message.AudioDestinationKey, + message.AudioProcessedFolderKey, + message.Sizes)) + .TrackFaults(message.TrackId, RoutingSlipVariableNames.ProcessKinds.TrackCreation); - builder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, message.Bucket); - builder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, message.PictureDestinationKey); - builder.AddVariable(RoutingSlipVariableNames.Workflow.AudioKey, message.AudioDestinationKey); + builder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, message.Bucket); + builder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, message.PictureDestinationKey); + builder.AddVariable(RoutingSlipVariableNames.Workflow.AudioKey, message.AudioDestinationKey); - return builder; + return builder; + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteRoutingSlipBuilder.cs index 694c7ff..6e5f6e3 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteRoutingSlipBuilder.cs @@ -6,130 +6,131 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Activities.LifeCycle; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; - -/// Marks an entity as removing, deletes its files from the bucket and finally deletes its row. -public class DeleteRoutingSlipBuilder( - IDatabase database, - TrackConfiguration trackConfiguration, - AlbumConfiguration albumConfiguration, - PlayListConfiguration playListConfiguration, - ApplicationStorageConfiguration storageConfiguration) +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders { - private sealed record PictureStepNames(string Original, string Small, string Medium, string Large); - - private static readonly PictureStepNames AlbumSteps = new( - ActivityNames.RemoveAlbumOriginalPicture, ActivityNames.RemoveAlbumSmallPicture, - ActivityNames.RemoveAlbumMediumPicture, ActivityNames.RemoveAlbumLargePicture); - - private static readonly PictureStepNames PlayListSteps = new( - ActivityNames.RemovePlayListOriginalPicture, ActivityNames.RemovePlayListSmallPicture, - ActivityNames.RemovePlayListMediumPicture, ActivityNames.RemovePlayListLargePicture); - - public async Task BuildTrackAsync( - Guid trackId, long userId, Guid? correlationId, CancellationToken cancellationToken) + /// Marks an entity as removing, deletes its files from the bucket and finally deletes its row. + public class DeleteRoutingSlipBuilder( + IDatabase database, + TrackConfiguration trackConfiguration, + AlbumConfiguration albumConfiguration, + PlayListConfiguration playListConfiguration, + ApplicationStorageConfiguration storageConfiguration) { - var builder = RoutingSlips.Create(correlationId) - .AddStep( - ActivityNames.MarkTrackAsRemoving, - MarkTrackLifeCycleActivity.ExecuteEndpointName, - new MarkLifeCycleArguments(trackId, LifeCycleStatus.Removing)); + private sealed record PictureStepNames(string Original, string Small, string Medium, string Large); - var track = await database.Tracks.AsNoTracking().SingleOrDefaultAsync(t => t.Id == trackId, cancellationToken); - if (track != null) + private static readonly PictureStepNames AlbumSteps = new( + ActivityNames.RemoveAlbumOriginalPicture, ActivityNames.RemoveAlbumSmallPicture, + ActivityNames.RemoveAlbumMediumPicture, ActivityNames.RemoveAlbumLargePicture); + + private static readonly PictureStepNames PlayListSteps = new( + ActivityNames.RemovePlayListOriginalPicture, ActivityNames.RemovePlayListSmallPicture, + ActivityNames.RemovePlayListMediumPicture, ActivityNames.RemovePlayListLargePicture); + + public async Task BuildTrackAsync( + Guid trackId, long userId, Guid? correlationId, CancellationToken cancellationToken) { - var bucket = storageConfiguration.Bucket; - var routes = trackConfiguration.Routes; + var builder = RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.MarkTrackAsRemoving, + MarkTrackLifeCycleActivity.ExecuteEndpointName, + new MarkLifeCycleArguments(trackId, LifeCycleStatus.Removing)); - if (!string.IsNullOrEmpty(track.Pictures.OriginalName)) + var track = await database.Tracks.AsNoTracking().SingleOrDefaultAsync(t => t.Id == trackId, cancellationToken); + if (track != null) { - builder.AddRemoveFile( - ActivityNames.RemoveTrackOriginalPicture, bucket, - routes.BuildOriginalPicturePath(userId, track.Pictures.OriginalName)); + var bucket = storageConfiguration.Bucket; + var routes = trackConfiguration.Routes; + + if (!string.IsNullOrEmpty(track.Pictures.OriginalName)) + { + builder.AddRemoveFile( + ActivityNames.RemoveTrackOriginalPicture, bucket, + routes.BuildOriginalPicturePath(userId, track.Pictures.OriginalName)); + } + + // Unprocessed tracks still point at the shared preset pictures, which must never be deleted. + if (track.Pictures.IsProcessed) + { + builder.AddRemoveFile(ActivityNames.RemoveTrackSmallPicture, bucket, routes.BuildPicturePath(PictureSize.Small, track.Pictures.SmallName)); + builder.AddRemoveFile(ActivityNames.RemoveTrackMediumPicture, bucket, routes.BuildPicturePath(PictureSize.Medium, track.Pictures.MediumName)); + builder.AddRemoveFile(ActivityNames.RemoveTrackLargePicture, bucket, routes.BuildPicturePath(PictureSize.Large, track.Pictures.LargeName)); + } + + if (!string.IsNullOrEmpty(track.Audio.OriginalName)) + { + builder.AddRemoveFile( + ActivityNames.RemoveTrackOriginalAudio, bucket, + routes.BuildOriginalAudioPath(userId, track.Audio.OriginalName)); + } + + if (track.Audio.IsProcessed) + { + builder.AddRemoveFile( + ActivityNames.RemoveTrackProcessedAudio, bucket, + routes.BuildProcessedAudioPath(track.Audio.FolderName)); + } } - // Unprocessed tracks still point at the shared preset pictures, which must never be deleted. - if (track.Pictures.IsProcessed) - { - builder.AddRemoveFile(ActivityNames.RemoveTrackSmallPicture, bucket, routes.BuildPicturePath(PictureSize.Small, track.Pictures.SmallName)); - builder.AddRemoveFile(ActivityNames.RemoveTrackMediumPicture, bucket, routes.BuildPicturePath(PictureSize.Medium, track.Pictures.MediumName)); - builder.AddRemoveFile(ActivityNames.RemoveTrackLargePicture, bucket, routes.BuildPicturePath(PictureSize.Large, track.Pictures.LargeName)); - } - - if (!string.IsNullOrEmpty(track.Audio.OriginalName)) - { - builder.AddRemoveFile( - ActivityNames.RemoveTrackOriginalAudio, bucket, - routes.BuildOriginalAudioPath(userId, track.Audio.OriginalName)); - } - - if (track.Audio.IsProcessed) - { - builder.AddRemoveFile( - ActivityNames.RemoveTrackProcessedAudio, bucket, - routes.BuildProcessedAudioPath(track.Audio.FolderName)); - } + return builder.AddStep( + ActivityNames.DeleteTrackFromDb, DeleteTrackActivity.ExecuteEndpointName, new DeleteEntityArguments(trackId)); } - return builder.AddStep( - ActivityNames.DeleteTrackFromDb, DeleteTrackActivity.ExecuteEndpointName, new DeleteEntityArguments(trackId)); - } + public async Task BuildAlbumAsync( + Guid albumId, long userId, Guid? correlationId, CancellationToken cancellationToken) + { + var builder = RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.MarkAlbumAsRemoving, + MarkAlbumLifeCycleActivity.ExecuteEndpointName, + new MarkLifeCycleArguments(albumId, LifeCycleStatus.Removing)); - public async Task BuildAlbumAsync( - Guid albumId, long userId, Guid? correlationId, CancellationToken cancellationToken) - { - var builder = RoutingSlips.Create(correlationId) - .AddStep( - ActivityNames.MarkAlbumAsRemoving, - MarkAlbumLifeCycleActivity.ExecuteEndpointName, - new MarkLifeCycleArguments(albumId, LifeCycleStatus.Removing)); + var pictures = await database.Albums.AsNoTracking() + .Where(a => a.Id == albumId) + .Select(a => a.Pictures) + .SingleOrDefaultAsync(cancellationToken); - var pictures = await database.Albums.AsNoTracking() - .Where(a => a.Id == albumId) - .Select(a => a.Pictures) - .SingleOrDefaultAsync(cancellationToken); + AddPictureRemovals(builder, AlbumSteps, albumConfiguration.Routes, userId, pictures); - AddPictureRemovals(builder, AlbumSteps, albumConfiguration.Routes, userId, pictures); + return builder.AddStep( + ActivityNames.DeleteAlbumFromDb, DeleteAlbumActivity.ExecuteEndpointName, new DeleteEntityArguments(albumId)); + } - return builder.AddStep( - ActivityNames.DeleteAlbumFromDb, DeleteAlbumActivity.ExecuteEndpointName, new DeleteEntityArguments(albumId)); - } + public async Task BuildPlayListAsync( + Guid playListId, long userId, Guid? correlationId, CancellationToken cancellationToken) + { + var builder = RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.MarkPlayListAsRemoving, + MarkPlayListLifeCycleActivity.ExecuteEndpointName, + new MarkLifeCycleArguments(playListId, LifeCycleStatus.Removing)); - public async Task BuildPlayListAsync( - Guid playListId, long userId, Guid? correlationId, CancellationToken cancellationToken) - { - var builder = RoutingSlips.Create(correlationId) - .AddStep( - ActivityNames.MarkPlayListAsRemoving, - MarkPlayListLifeCycleActivity.ExecuteEndpointName, - new MarkLifeCycleArguments(playListId, LifeCycleStatus.Removing)); + var pictures = await database.PlayLists.AsNoTracking() + .Where(p => p.Id == playListId) + .Select(p => p.Pictures) + .SingleOrDefaultAsync(cancellationToken); - var pictures = await database.PlayLists.AsNoTracking() - .Where(p => p.Id == playListId) - .Select(p => p.Pictures) - .SingleOrDefaultAsync(cancellationToken); + AddPictureRemovals(builder, PlayListSteps, playListConfiguration.Routes, userId, pictures); - AddPictureRemovals(builder, PlayListSteps, playListConfiguration.Routes, userId, pictures); + return builder.AddStep( + ActivityNames.DeletePlayListFromDb, DeletePlayListActivity.ExecuteEndpointName, new DeleteEntityArguments(playListId)); + } - return builder.AddStep( - ActivityNames.DeletePlayListFromDb, DeletePlayListActivity.ExecuteEndpointName, new DeleteEntityArguments(playListId)); - } + private void AddPictureRemovals( + RoutingSlipBuilder builder, PictureStepNames steps, PictureRoutes routes, long userId, EntityPictures? pictures) + { + if (pictures == null) + return; - private void AddPictureRemovals( - RoutingSlipBuilder builder, PictureStepNames steps, PictureRoutes routes, long userId, EntityPictures? pictures) - { - if (pictures == null) - return; + var bucket = storageConfiguration.Bucket; - var bucket = storageConfiguration.Bucket; - - if (pictures.OriginalName is { } original) - builder.AddRemoveFile(steps.Original, bucket, routes.BuildOriginalPicturePath(userId, original)); - if (pictures.SmallName is { } small) - builder.AddRemoveFile(steps.Small, bucket, routes.BuildPicturePath(PictureSize.Small, small)); - if (pictures.MediumName is { } medium) - builder.AddRemoveFile(steps.Medium, bucket, routes.BuildPicturePath(PictureSize.Medium, medium)); - if (pictures.LargeName is { } large) - builder.AddRemoveFile(steps.Large, bucket, routes.BuildPicturePath(PictureSize.Large, large)); + if (pictures.OriginalName is { } original) + builder.AddRemoveFile(steps.Original, bucket, routes.BuildOriginalPicturePath(userId, original)); + if (pictures.SmallName is { } small) + builder.AddRemoveFile(steps.Small, bucket, routes.BuildPicturePath(PictureSize.Small, small)); + if (pictures.MediumName is { } medium) + builder.AddRemoveFile(steps.Medium, bucket, routes.BuildPicturePath(PictureSize.Medium, medium)); + if (pictures.LargeName is { } large) + builder.AddRemoveFile(steps.Large, bucket, routes.BuildPicturePath(PictureSize.Large, large)); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureSourceRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureSourceRoutingSlipBuilder.cs index 2069f14..6395130 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureSourceRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureSourceRoutingSlipBuilder.cs @@ -6,69 +6,70 @@ using Musify.Infrastructure.MassTransit.Activities.PlayLists; using Musify.Infrastructure.MassTransit.Activities.UploadIntents; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; - -/// -/// Moves an uploaded album/playlist picture to its final key, consumes the upload intent and hands over to the picture workflow. -/// Creation faults mark the whole entity as failed; update faults only report the picture as failed. -/// -public class PictureSourceRoutingSlipBuilder +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders { - public RoutingSlipBuilder Build(CreateAlbumResourcesEvent message, Guid? correlationId) => - BuildSlip( - message.AlbumId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, - correlationId, RoutingSlipVariableNames.ProcessKinds.AlbumCreation, - ActivityNames.PublishAlbumPictureProcessingEvent, PublishAlbumPictureProcessingEventActivity.ExecuteEndpointName, - new PublishAlbumPictureProcessingEventArguments(message.AlbumId, message.Bucket, message.PictureDestinationKey, message.Sizes)); - - public RoutingSlipBuilder Build(UpdateAlbumPictureSourceEvent message, Guid? correlationId) => - BuildSlip( - message.AlbumId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, - correlationId, RoutingSlipVariableNames.ProcessKinds.AlbumPicture, - ActivityNames.PublishAlbumPictureProcessingEvent, PublishAlbumPictureProcessingEventActivity.ExecuteEndpointName, - new PublishAlbumPictureProcessingEventArguments(message.AlbumId, message.Bucket, message.PictureDestinationKey, message.Sizes)); - - public RoutingSlipBuilder Build(CreatePlayListResourcesEvent message, Guid? correlationId) => - BuildSlip( - message.PlayListId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, - correlationId, RoutingSlipVariableNames.ProcessKinds.PlayListCreation, - ActivityNames.PublishPlayListPictureProcessingEvent, PublishPlayListPictureProcessingEventActivity.ExecuteEndpointName, - new PublishPlayListPictureProcessingEventArguments(message.PlayListId, message.Bucket, message.PictureDestinationKey, message.Sizes)); - - public RoutingSlipBuilder Build(UpdatePlayListPictureSourceEvent message, Guid? correlationId) => - BuildSlip( - message.PlayListId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, - correlationId, RoutingSlipVariableNames.ProcessKinds.PlayListPicture, - ActivityNames.PublishPlayListPictureProcessingEvent, PublishPlayListPictureProcessingEventActivity.ExecuteEndpointName, - new PublishPlayListPictureProcessingEventArguments(message.PlayListId, message.Bucket, message.PictureDestinationKey, message.Sizes)); - - private static RoutingSlipBuilder BuildSlip( - Guid subjectId, - Guid pictureIntentId, - string bucket, - string pictureSourceKey, - string pictureDestinationKey, - Guid? correlationId, - string processKind, - string publishStepName, - string publishEndpointName, - object publishArguments) + /// + /// Moves an uploaded album/playlist picture to its final key, consumes the upload intent and hands over to the picture workflow. + /// Creation faults mark the whole entity as failed; update faults only report the picture as failed. + /// + public class PictureSourceRoutingSlipBuilder { - var builder = RoutingSlips.Create(correlationId) - .AddStep( - ActivityNames.CopyPictureToFinal, - CopyFileInBucketActivity.ExecuteEndpointName, - new CopyFileInBucketArguments(bucket, pictureSourceKey, bucket, pictureDestinationKey)) - .AddStep( - ActivityNames.ConsumeUploadIntents, - ConsumeUploadIntentsActivity.ExecuteEndpointName, - new ConsumeUploadIntentsArguments([pictureIntentId])) - .AddStep(publishStepName, publishEndpointName, publishArguments) - .TrackFaults(subjectId, processKind); + public RoutingSlipBuilder Build(CreateAlbumResourcesEvent message, Guid? correlationId) => + BuildSlip( + message.AlbumId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, + correlationId, RoutingSlipVariableNames.ProcessKinds.AlbumCreation, + ActivityNames.PublishAlbumPictureProcessingEvent, PublishAlbumPictureProcessingEventActivity.ExecuteEndpointName, + new PublishAlbumPictureProcessingEventArguments(message.AlbumId, message.Bucket, message.PictureDestinationKey, message.Sizes)); - builder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, bucket); - builder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, pictureDestinationKey); + public RoutingSlipBuilder Build(UpdateAlbumPictureSourceEvent message, Guid? correlationId) => + BuildSlip( + message.AlbumId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, + correlationId, RoutingSlipVariableNames.ProcessKinds.AlbumPicture, + ActivityNames.PublishAlbumPictureProcessingEvent, PublishAlbumPictureProcessingEventActivity.ExecuteEndpointName, + new PublishAlbumPictureProcessingEventArguments(message.AlbumId, message.Bucket, message.PictureDestinationKey, message.Sizes)); - return builder; + public RoutingSlipBuilder Build(CreatePlayListResourcesEvent message, Guid? correlationId) => + BuildSlip( + message.PlayListId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, + correlationId, RoutingSlipVariableNames.ProcessKinds.PlayListCreation, + ActivityNames.PublishPlayListPictureProcessingEvent, PublishPlayListPictureProcessingEventActivity.ExecuteEndpointName, + new PublishPlayListPictureProcessingEventArguments(message.PlayListId, message.Bucket, message.PictureDestinationKey, message.Sizes)); + + public RoutingSlipBuilder Build(UpdatePlayListPictureSourceEvent message, Guid? correlationId) => + BuildSlip( + message.PlayListId, message.PictureIntentId, message.Bucket, message.PictureSourceKey, message.PictureDestinationKey, + correlationId, RoutingSlipVariableNames.ProcessKinds.PlayListPicture, + ActivityNames.PublishPlayListPictureProcessingEvent, PublishPlayListPictureProcessingEventActivity.ExecuteEndpointName, + new PublishPlayListPictureProcessingEventArguments(message.PlayListId, message.Bucket, message.PictureDestinationKey, message.Sizes)); + + private static RoutingSlipBuilder BuildSlip( + Guid subjectId, + Guid pictureIntentId, + string bucket, + string pictureSourceKey, + string pictureDestinationKey, + Guid? correlationId, + string processKind, + string publishStepName, + string publishEndpointName, + object publishArguments) + { + var builder = RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.CopyPictureToFinal, + CopyFileInBucketActivity.ExecuteEndpointName, + new CopyFileInBucketArguments(bucket, pictureSourceKey, bucket, pictureDestinationKey)) + .AddStep( + ActivityNames.ConsumeUploadIntents, + ConsumeUploadIntentsActivity.ExecuteEndpointName, + new ConsumeUploadIntentsArguments([pictureIntentId])) + .AddStep(publishStepName, publishEndpointName, publishArguments) + .TrackFaults(subjectId, processKind); + + builder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, bucket); + builder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, pictureDestinationKey); + + return builder; + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs index bf81fe6..07cfcda 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs @@ -6,81 +6,82 @@ using Musify.Infrastructure.MassTransit.Activities.Files; using Musify.Infrastructure.MassTransit.Activities.Pictures; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; - -/// Downloads a picture, resizes it to the three sizes, uploads them and records the result on the owner. -public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration) +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders { - public RoutingSlipBuilder Build(UpdateTrackPictureEvent message, Guid? correlationId) => - BuildPictureWorkflow( - message.TrackId, message.Bucket, message.SourceKey, message.Sizes, correlationId, - ActivityNames.UpdateTrackPicture, UpdateTrackPictureActivity.ExecuteEndpointName, - RoutingSlipVariableNames.ProcessKinds.TrackPicture); - - public RoutingSlipBuilder Build(UpdatePlayListPictureEvent message, Guid? correlationId) => - BuildPictureWorkflow( - message.PlayListId, message.Bucket, message.SourceKey, message.Sizes, correlationId, - ActivityNames.UpdatePlayListPicture, UpdatePlayListPictureActivity.ExecuteEndpointName, - RoutingSlipVariableNames.ProcessKinds.PlayListPicture); - - public RoutingSlipBuilder Build(UpdateAlbumPictureEvent message, Guid? correlationId) => - BuildPictureWorkflow( - message.AlbumId, message.Bucket, message.SourceKey, message.Sizes, correlationId, - ActivityNames.UpdateAlbumPicture, UpdateAlbumPictureActivity.ExecuteEndpointName, - RoutingSlipVariableNames.ProcessKinds.AlbumPicture); - - private RoutingSlipBuilder BuildPictureWorkflow( - Guid subjectId, - string bucket, - string sourceKey, - ImageSizes sizes, - Guid? correlationId, - string updateStepName, - string updateEndpointName, - string processKind) + /// Downloads a picture, resizes it to the three sizes, uploads them and records the result on the owner. + public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration) { - var builder = RoutingSlips.Create(correlationId) - .AddStep( - ActivityNames.GeneratePictureWorkflowPaths, - GeneratePictureWorkflowPathsActivity.ExecuteEndpointName, - new GeneratePictureWorkflowPathsArguments(workerConfiguration.Routes.TemporaryFilesDirectory, sourceKey)) - .AddStep( - ActivityNames.DownloadFile, - DownloadFileFromBucketActivity.ExecuteEndpointName, - new DownloadFileFromBucketArguments(bucket, sourceKey, RoutingSlipVariableNames.Picture.OriginalFilePath)); + public RoutingSlipBuilder Build(UpdateTrackPictureEvent message, Guid? correlationId) => + BuildPictureWorkflow( + message.TrackId, message.Bucket, message.SourceKey, message.Sizes, correlationId, + ActivityNames.UpdateTrackPicture, UpdateTrackPictureActivity.ExecuteEndpointName, + RoutingSlipVariableNames.ProcessKinds.TrackPicture); - AddResize(builder, ActivityNames.ResizeSmall, ActivityNames.UploadSmall, bucket, RoutingSlipVariableNames.Picture.SmallResizedFilePath, sizes.Small); - AddResize(builder, ActivityNames.ResizeMedium, ActivityNames.UploadMedium, bucket, RoutingSlipVariableNames.Picture.MediumResizedFilePath, sizes.Medium); - AddResize(builder, ActivityNames.ResizeLarge, ActivityNames.UploadLarge, bucket, RoutingSlipVariableNames.Picture.LargeResizedFilePath, sizes.Large); + public RoutingSlipBuilder Build(UpdatePlayListPictureEvent message, Guid? correlationId) => + BuildPictureWorkflow( + message.PlayListId, message.Bucket, message.SourceKey, message.Sizes, correlationId, + ActivityNames.UpdatePlayListPicture, UpdatePlayListPictureActivity.ExecuteEndpointName, + RoutingSlipVariableNames.ProcessKinds.PlayListPicture); - return builder - .AddStep( - updateStepName, - updateEndpointName, - new UpdatePicturesArguments( - subjectId, - sourceKey, - RoutingSlipVariableNames.Picture.SmallResizedFilePath, - RoutingSlipVariableNames.Picture.MediumResizedFilePath, - RoutingSlipVariableNames.Picture.LargeResizedFilePath)) - .TrackFaults(subjectId, processKind); + public RoutingSlipBuilder Build(UpdateAlbumPictureEvent message, Guid? correlationId) => + BuildPictureWorkflow( + message.AlbumId, message.Bucket, message.SourceKey, message.Sizes, correlationId, + ActivityNames.UpdateAlbumPicture, UpdateAlbumPictureActivity.ExecuteEndpointName, + RoutingSlipVariableNames.ProcessKinds.AlbumPicture); + + private RoutingSlipBuilder BuildPictureWorkflow( + Guid subjectId, + string bucket, + string sourceKey, + ImageSizes sizes, + Guid? correlationId, + string updateStepName, + string updateEndpointName, + string processKind) + { + var builder = RoutingSlips.Create(correlationId) + .AddStep( + ActivityNames.GeneratePictureWorkflowPaths, + GeneratePictureWorkflowPathsActivity.ExecuteEndpointName, + new GeneratePictureWorkflowPathsArguments(workerConfiguration.Routes.TemporaryFilesDirectory, sourceKey)) + .AddStep( + ActivityNames.DownloadFile, + DownloadFileFromBucketActivity.ExecuteEndpointName, + new DownloadFileFromBucketArguments(bucket, sourceKey, RoutingSlipVariableNames.Picture.OriginalFilePath)); + + AddResize(builder, ActivityNames.ResizeSmall, ActivityNames.UploadSmall, bucket, RoutingSlipVariableNames.Picture.SmallResizedFilePath, sizes.Small); + AddResize(builder, ActivityNames.ResizeMedium, ActivityNames.UploadMedium, bucket, RoutingSlipVariableNames.Picture.MediumResizedFilePath, sizes.Medium); + AddResize(builder, ActivityNames.ResizeLarge, ActivityNames.UploadLarge, bucket, RoutingSlipVariableNames.Picture.LargeResizedFilePath, sizes.Large); + + return builder + .AddStep( + updateStepName, + updateEndpointName, + new UpdatePicturesArguments( + subjectId, + sourceKey, + RoutingSlipVariableNames.Picture.SmallResizedFilePath, + RoutingSlipVariableNames.Picture.MediumResizedFilePath, + RoutingSlipVariableNames.Picture.LargeResizedFilePath)) + .TrackFaults(subjectId, processKind); + } + + private static void AddResize( + RoutingSlipBuilder builder, + string resizeStepName, + string uploadStepName, + string bucket, + string resizedFileVariable, + ImageSize size) => + builder + .AddStep( + resizeStepName, + ResizePictureActivity.ExecuteEndpointName, + new ResizePictureLocalArguments( + RoutingSlipVariableNames.Picture.OriginalFilePath, resizedFileVariable, size.Width, size.Height)) + .AddStep( + uploadStepName, + UploadFileToBucketActivity.ExecuteEndpointName, + new UploadFileToBucketArguments(resizedFileVariable, bucket, size.SavePath)); } - - private static void AddResize( - RoutingSlipBuilder builder, - string resizeStepName, - string uploadStepName, - string bucket, - string resizedFileVariable, - ImageSize size) => - builder - .AddStep( - resizeStepName, - ResizePictureActivity.ExecuteEndpointName, - new ResizePictureLocalArguments( - RoutingSlipVariableNames.Picture.OriginalFilePath, resizedFileVariable, size.Width, size.Height)) - .AddStep( - uploadStepName, - UploadFileToBucketActivity.ExecuteEndpointName, - new UploadFileToBucketArguments(resizedFileVariable, bucket, size.SavePath)); } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs index 393aea4..6290468 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs @@ -1,41 +1,42 @@ -namespace Musify.Infrastructure.MassTransit.RoutingSlip; - -internal static class RoutingSlipVariableNames +namespace Musify.Infrastructure.MassTransit.RoutingSlip { - internal static class Workflow + internal static class RoutingSlipVariableNames { - internal const string TemporalDirectory = "Workflow.TemporalDirectory"; - internal const string CorrelationId = "Workflow.CorrelationId"; - internal const string SubjectId = "Workflow.SubjectId"; - internal const string ProcessKind = "Workflow.ProcessKind"; - internal const string Bucket = "Workflow.Bucket"; - internal const string PictureKey = "Workflow.PictureKey"; - internal const string AudioKey = "Workflow.AudioKey"; - } + internal static class Workflow + { + internal const string TemporalDirectory = "Workflow.TemporalDirectory"; + internal const string CorrelationId = "Workflow.CorrelationId"; + internal const string SubjectId = "Workflow.SubjectId"; + internal const string ProcessKind = "Workflow.ProcessKind"; + internal const string Bucket = "Workflow.Bucket"; + internal const string PictureKey = "Workflow.PictureKey"; + internal const string AudioKey = "Workflow.AudioKey"; + } - internal static class ProcessKinds - { - internal const string TrackPicture = "TrackPicture"; - internal const string TrackAudio = "TrackAudio"; - internal const string PlayListPicture = "PlayListPicture"; - internal const string AlbumPicture = "AlbumPicture"; - internal const string TrackCreation = "TrackCreation"; - internal const string AlbumCreation = "AlbumCreation"; - internal const string PlayListCreation = "PlayListCreation"; - } + internal static class ProcessKinds + { + internal const string TrackPicture = "TrackPicture"; + internal const string TrackAudio = "TrackAudio"; + internal const string PlayListPicture = "PlayListPicture"; + internal const string AlbumPicture = "AlbumPicture"; + internal const string TrackCreation = "TrackCreation"; + internal const string AlbumCreation = "AlbumCreation"; + internal const string PlayListCreation = "PlayListCreation"; + } - internal static class Audio - { - internal const string SourceFilePath = "Audio.SourceFilePath"; - internal const string TranscodedDirectory = "Audio.TranscodedDirectory"; - internal const string DurationSeconds = "Audio.DurationSeconds"; - } + internal static class Audio + { + internal const string SourceFilePath = "Audio.SourceFilePath"; + internal const string TranscodedDirectory = "Audio.TranscodedDirectory"; + internal const string DurationSeconds = "Audio.DurationSeconds"; + } - internal static class Picture - { - internal const string OriginalFilePath = "Picture.OriginalFilePath"; - internal const string SmallResizedFilePath = "Picture.SmallResizedFilePath"; - internal const string MediumResizedFilePath = "Picture.MediumResizedFilePath"; - internal const string LargeResizedFilePath = "Picture.LargeResizedFilePath"; + internal static class Picture + { + internal const string OriginalFilePath = "Picture.OriginalFilePath"; + internal const string SmallResizedFilePath = "Picture.SmallResizedFilePath"; + internal const string MediumResizedFilePath = "Picture.MediumResizedFilePath"; + internal const string LargeResizedFilePath = "Picture.LargeResizedFilePath"; + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlips.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlips.cs index 7134165..b9cd391 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlips.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlips.cs @@ -4,45 +4,46 @@ using Musify.Infrastructure.MassTransit.Activities.Files; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Consumers; -namespace Musify.Infrastructure.MassTransit.RoutingSlip; - -/// Shared building blocks for every routing slip in the worker. -internal static class RoutingSlips +namespace Musify.Infrastructure.MassTransit.RoutingSlip { - public static Uri ExecuteUri(string endpointName) => new($"queue:{endpointName}_execute"); - - public static Uri ConsumerUri(string queueName) => new($"queue:{queueName}"); - - /// A new slip that carries the correlation id and always cleans its temporary directory up. - public static RoutingSlipBuilder Create(Guid? correlationId) + /// Shared building blocks for every routing slip in the worker. + internal static class RoutingSlips { - var builder = new RoutingSlipBuilder(NewId.NextGuid()); - builder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); - builder.AddSubscription( - ConsumerUri(RoutingSlipCleanUpConsumer.QueueName), - RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + public static Uri ExecuteUri(string endpointName) => new($"queue:{endpointName}_execute"); - return builder; + public static Uri ConsumerUri(string queueName) => new($"queue:{queueName}"); + + /// A new slip that carries the correlation id and always cleans its temporary directory up. + public static RoutingSlipBuilder Create(Guid? correlationId) + { + var builder = new RoutingSlipBuilder(NewId.NextGuid()); + builder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + builder.AddSubscription( + ConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + + return builder; + } + + public static RoutingSlipBuilder AddStep( + this RoutingSlipBuilder builder, string name, string executeEndpointName, object arguments) + { + builder.AddActivity(name, ExecuteUri(executeEndpointName), arguments); + return builder; + } + + /// Publishes a failure event for the subject when the slip faults. + public static RoutingSlipBuilder TrackFaults( + this RoutingSlipBuilder builder, Guid subjectId, string processKind) + { + builder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, subjectId); + builder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, processKind); + builder.AddSubscription(ConsumerUri(ProcessingSlipFaultConsumer.QueueName), RoutingSlipEvents.Faulted); + return builder; + } + + public static RoutingSlipBuilder AddRemoveFile( + this RoutingSlipBuilder builder, string stepName, string bucket, string key) => + builder.AddStep(stepName, RemoveFileFromBucketActivity.ExecuteEndpointName, new RemoveFileFromBucketArguments(bucket, key)); } - - public static RoutingSlipBuilder AddStep( - this RoutingSlipBuilder builder, string name, string executeEndpointName, object arguments) - { - builder.AddActivity(name, ExecuteUri(executeEndpointName), arguments); - return builder; - } - - /// Publishes a failure event for the subject when the slip faults. - public static RoutingSlipBuilder TrackFaults( - this RoutingSlipBuilder builder, Guid subjectId, string processKind) - { - builder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, subjectId); - builder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, processKind); - builder.AddSubscription(ConsumerUri(ProcessingSlipFaultConsumer.QueueName), RoutingSlipEvents.Faulted); - return builder; - } - - public static RoutingSlipBuilder AddRemoveFile( - this RoutingSlipBuilder builder, string stepName, string bucket, string key) => - builder.AddStep(stepName, RemoveFileFromBucketActivity.ExecuteEndpointName, new RemoveFileFromBucketArguments(bucket, key)); } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingState.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingState.cs index 0c2f23e..8229784 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingState.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingState.cs @@ -1,18 +1,19 @@ using MassTransit; -namespace Musify.Infrastructure.MassTransit.Sagas; - -public class AlbumProcessingState : SagaStateMachineInstance +namespace Musify.Infrastructure.MassTransit.Sagas { - public Guid CorrelationId { get; set; } + public class AlbumProcessingState : SagaStateMachineInstance + { + public Guid CorrelationId { get; set; } - public string CurrentState { get; set; } = null!; + public string CurrentState { get; set; } = null!; - public string? Bucket { get; set; } + public string? Bucket { get; set; } - public string? PictureKey { get; set; } + public string? PictureKey { get; set; } - public DateTime CreatedAt { get; set; } + public DateTime CreatedAt { get; set; } - public DateTime UpdatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs index 3eaa300..5c66b3e 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs @@ -1,58 +1,59 @@ using MassTransit; using Musify.Application.Events; -namespace Musify.Infrastructure.MassTransit.Sagas; - -public sealed class AlbumProcessingStateMachine : MassTransitStateMachine +namespace Musify.Infrastructure.MassTransit.Sagas { - public State Processing { get; private set; } = null!; - public State Failed { get; private set; } = null!; - - public Event ProcessingStarted { get; private set; } = null!; - public Event PictureProcessed { get; private set; } = null!; - public Event PictureFailed { get; private set; } = null!; - - public AlbumProcessingStateMachine() + public sealed class AlbumProcessingStateMachine : MassTransitStateMachine { - InstanceState(state => state.CurrentState); + public State Processing { get; private set; } = null!; + public State Failed { get; private set; } = null!; - Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.AlbumId)); - Event(() => PictureProcessed, config => + public Event ProcessingStarted { get; private set; } = null!; + public Event PictureProcessed { get; private set; } = null!; + public Event PictureFailed { get; private set; } = null!; + + public AlbumProcessingStateMachine() { - config.CorrelateById(context => context.Message.AlbumId); - config.OnMissingInstance(instance => instance.Discard()); - }); - Event(() => PictureFailed, config => - { - config.CorrelateById(context => context.Message.AlbumId); - config.OnMissingInstance(instance => instance.Discard()); - }); + InstanceState(state => state.CurrentState); - Initially( - When(ProcessingStarted) - .Then(context => - { - context.Saga.CreatedAt = DateTime.UtcNow; - context.Saga.UpdatedAt = DateTime.UtcNow; - context.Saga.Bucket = context.Message.Bucket; - context.Saga.PictureKey = context.Message.PictureDestinationKey; - }) - .TransitionTo(Processing)); + Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.AlbumId)); + Event(() => PictureProcessed, config => + { + config.CorrelateById(context => context.Message.AlbumId); + config.OnMissingInstance(instance => instance.Discard()); + }); + Event(() => PictureFailed, config => + { + config.CorrelateById(context => context.Message.AlbumId); + config.OnMissingInstance(instance => instance.Discard()); + }); - During(Processing, - When(PictureProcessed).Finalize(), - When(PictureFailed) - .Then(context => context.Saga.UpdatedAt = DateTime.UtcNow) - .TransitionTo(Failed) - .Publish(context => new AlbumProcessingFailed( - context.Saga.CorrelationId, - context.Saga.Bucket, - context.Saga.PictureKey))); + Initially( + When(ProcessingStarted) + .Then(context => + { + context.Saga.CreatedAt = DateTime.UtcNow; + context.Saga.UpdatedAt = DateTime.UtcNow; + context.Saga.Bucket = context.Message.Bucket; + context.Saga.PictureKey = context.Message.PictureDestinationKey; + }) + .TransitionTo(Processing)); - During(Failed, - Ignore(PictureProcessed), - Ignore(PictureFailed)); + During(Processing, + When(PictureProcessed).Finalize(), + When(PictureFailed) + .Then(context => context.Saga.UpdatedAt = DateTime.UtcNow) + .TransitionTo(Failed) + .Publish(context => new AlbumProcessingFailed( + context.Saga.CorrelationId, + context.Saga.Bucket, + context.Saga.PictureKey))); - SetCompletedWhenFinalized(); + During(Failed, + Ignore(PictureProcessed), + Ignore(PictureFailed)); + + SetCompletedWhenFinalized(); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingState.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingState.cs index 4d3c261..213112b 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingState.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingState.cs @@ -1,18 +1,19 @@ using MassTransit; -namespace Musify.Infrastructure.MassTransit.Sagas; - -public class PlayListProcessingState : SagaStateMachineInstance +namespace Musify.Infrastructure.MassTransit.Sagas { - public Guid CorrelationId { get; set; } + public class PlayListProcessingState : SagaStateMachineInstance + { + public Guid CorrelationId { get; set; } - public string CurrentState { get; set; } = null!; + public string CurrentState { get; set; } = null!; - public string? Bucket { get; set; } + public string? Bucket { get; set; } - public string? PictureKey { get; set; } + public string? PictureKey { get; set; } - public DateTime CreatedAt { get; set; } + public DateTime CreatedAt { get; set; } - public DateTime UpdatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingStateMachine.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingStateMachine.cs index 3ab7e6c..3721227 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingStateMachine.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingStateMachine.cs @@ -1,58 +1,59 @@ using MassTransit; using Musify.Application.Events; -namespace Musify.Infrastructure.MassTransit.Sagas; - -public sealed class PlayListProcessingStateMachine : MassTransitStateMachine +namespace Musify.Infrastructure.MassTransit.Sagas { - public State Processing { get; private set; } = null!; - public State Failed { get; private set; } = null!; - - public Event ProcessingStarted { get; private set; } = null!; - public Event PictureProcessed { get; private set; } = null!; - public Event PictureFailed { get; private set; } = null!; - - public PlayListProcessingStateMachine() + public sealed class PlayListProcessingStateMachine : MassTransitStateMachine { - InstanceState(state => state.CurrentState); + public State Processing { get; private set; } = null!; + public State Failed { get; private set; } = null!; - Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.PlayListId)); - Event(() => PictureProcessed, config => + public Event ProcessingStarted { get; private set; } = null!; + public Event PictureProcessed { get; private set; } = null!; + public Event PictureFailed { get; private set; } = null!; + + public PlayListProcessingStateMachine() { - config.CorrelateById(context => context.Message.PlayListId); - config.OnMissingInstance(instance => instance.Discard()); - }); - Event(() => PictureFailed, config => - { - config.CorrelateById(context => context.Message.PlayListId); - config.OnMissingInstance(instance => instance.Discard()); - }); + InstanceState(state => state.CurrentState); - Initially( - When(ProcessingStarted) - .Then(context => - { - context.Saga.CreatedAt = DateTime.UtcNow; - context.Saga.UpdatedAt = DateTime.UtcNow; - context.Saga.Bucket = context.Message.Bucket; - context.Saga.PictureKey = context.Message.PictureDestinationKey; - }) - .TransitionTo(Processing)); + Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.PlayListId)); + Event(() => PictureProcessed, config => + { + config.CorrelateById(context => context.Message.PlayListId); + config.OnMissingInstance(instance => instance.Discard()); + }); + Event(() => PictureFailed, config => + { + config.CorrelateById(context => context.Message.PlayListId); + config.OnMissingInstance(instance => instance.Discard()); + }); - During(Processing, - When(PictureProcessed).Finalize(), - When(PictureFailed) - .Then(context => context.Saga.UpdatedAt = DateTime.UtcNow) - .TransitionTo(Failed) - .Publish(context => new PlayListProcessingFailed( - context.Saga.CorrelationId, - context.Saga.Bucket, - context.Saga.PictureKey))); + Initially( + When(ProcessingStarted) + .Then(context => + { + context.Saga.CreatedAt = DateTime.UtcNow; + context.Saga.UpdatedAt = DateTime.UtcNow; + context.Saga.Bucket = context.Message.Bucket; + context.Saga.PictureKey = context.Message.PictureDestinationKey; + }) + .TransitionTo(Processing)); - During(Failed, - Ignore(PictureProcessed), - Ignore(PictureFailed)); + During(Processing, + When(PictureProcessed).Finalize(), + When(PictureFailed) + .Then(context => context.Saga.UpdatedAt = DateTime.UtcNow) + .TransitionTo(Failed) + .Publish(context => new PlayListProcessingFailed( + context.Saga.CorrelationId, + context.Saga.Bucket, + context.Saga.PictureKey))); - SetCompletedWhenFinalized(); + During(Failed, + Ignore(PictureProcessed), + Ignore(PictureFailed)); + + SetCompletedWhenFinalized(); + } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingState.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingState.cs index 2d0397b..707a3d6 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingState.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingState.cs @@ -1,24 +1,25 @@ using MassTransit; -namespace Musify.Infrastructure.MassTransit.Sagas; - -public class TrackProcessingState : SagaStateMachineInstance +namespace Musify.Infrastructure.MassTransit.Sagas { - public Guid CorrelationId { get; set; } + public class TrackProcessingState : SagaStateMachineInstance + { + public Guid CorrelationId { get; set; } - public string CurrentState { get; set; } = null!; + public string CurrentState { get; set; } = null!; - public bool PictureProcessed { get; set; } + public bool PictureProcessed { get; set; } - public bool AudioProcessed { get; set; } + public bool AudioProcessed { get; set; } - public string? Bucket { get; set; } + public string? Bucket { get; set; } - public string? PictureKey { get; set; } + public string? PictureKey { get; set; } - public string? AudioKey { get; set; } + public string? AudioKey { get; set; } - public DateTime CreatedAt { get; set; } + public DateTime CreatedAt { get; set; } - public DateTime UpdatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs index 5fdc6de..70c376a 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs @@ -1,95 +1,96 @@ using MassTransit; using Musify.Application.Events; -namespace Musify.Infrastructure.MassTransit.Sagas; - -public sealed class TrackProcessingStateMachine : MassTransitStateMachine +namespace Musify.Infrastructure.MassTransit.Sagas { - public State Processing { get; private set; } = null!; - public State Failed { get; private set; } = null!; - - public Event ProcessingStarted { get; private set; } = null!; - public Event PictureProcessed { get; private set; } = null!; - public Event AudioProcessed { get; private set; } = null!; - public Event PictureFailed { get; private set; } = null!; - public Event AudioFailed { get; private set; } = null!; - - public TrackProcessingStateMachine() + public sealed class TrackProcessingStateMachine : MassTransitStateMachine { - InstanceState(state => state.CurrentState); + public State Processing { get; private set; } = null!; + public State Failed { get; private set; } = null!; - Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.TrackId)); - Event(() => PictureProcessed, config => + public Event ProcessingStarted { get; private set; } = null!; + public Event PictureProcessed { get; private set; } = null!; + public Event AudioProcessed { get; private set; } = null!; + public Event PictureFailed { get; private set; } = null!; + public Event AudioFailed { get; private set; } = null!; + + public TrackProcessingStateMachine() { - config.CorrelateById(context => context.Message.TrackId); - config.OnMissingInstance(instance => instance.Discard()); - }); - Event(() => AudioProcessed, config => - { - config.CorrelateById(context => context.Message.TrackId); - config.OnMissingInstance(instance => instance.Discard()); - }); - Event(() => PictureFailed, config => - { - config.CorrelateById(context => context.Message.TrackId); - config.OnMissingInstance(instance => instance.Discard()); - }); - Event(() => AudioFailed, config => - { - config.CorrelateById(context => context.Message.TrackId); - config.OnMissingInstance(instance => instance.Discard()); - }); + InstanceState(state => state.CurrentState); - Initially( - When(ProcessingStarted) - .Then(context => - { - context.Saga.CreatedAt = DateTime.UtcNow; - context.Saga.UpdatedAt = DateTime.UtcNow; - context.Saga.Bucket = context.Message.Bucket; - context.Saga.PictureKey = context.Message.PictureDestinationKey; - context.Saga.AudioKey = context.Message.AudioDestinationKey; - }) - .TransitionTo(Processing)); + Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.TrackId)); + Event(() => PictureProcessed, config => + { + config.CorrelateById(context => context.Message.TrackId); + config.OnMissingInstance(instance => instance.Discard()); + }); + Event(() => AudioProcessed, config => + { + config.CorrelateById(context => context.Message.TrackId); + config.OnMissingInstance(instance => instance.Discard()); + }); + Event(() => PictureFailed, config => + { + config.CorrelateById(context => context.Message.TrackId); + config.OnMissingInstance(instance => instance.Discard()); + }); + Event(() => AudioFailed, config => + { + config.CorrelateById(context => context.Message.TrackId); + config.OnMissingInstance(instance => instance.Discard()); + }); - During(Processing, - When(PictureProcessed) - .Then(context => - { - context.Saga.PictureProcessed = true; - context.Saga.UpdatedAt = DateTime.UtcNow; - }) - .If(context => context.Saga.AudioProcessed, binder => binder.Finalize()), - When(AudioProcessed) - .Then(context => - { - context.Saga.AudioProcessed = true; - context.Saga.UpdatedAt = DateTime.UtcNow; - }) - .If(context => context.Saga.PictureProcessed, binder => binder.Finalize()), - When(PictureFailed) - .Then(context => context.Saga.UpdatedAt = DateTime.UtcNow) - .TransitionTo(Failed) - .Publish(context => new TrackProcessingFailed( - context.Saga.CorrelationId, - context.Saga.Bucket, - context.Saga.PictureKey, - context.Saga.AudioKey)), - When(AudioFailed) - .Then(context => context.Saga.UpdatedAt = DateTime.UtcNow) - .TransitionTo(Failed) - .Publish(context => new TrackProcessingFailed( - context.Saga.CorrelationId, - context.Saga.Bucket, - context.Saga.PictureKey, - context.Saga.AudioKey))); + Initially( + When(ProcessingStarted) + .Then(context => + { + context.Saga.CreatedAt = DateTime.UtcNow; + context.Saga.UpdatedAt = DateTime.UtcNow; + context.Saga.Bucket = context.Message.Bucket; + context.Saga.PictureKey = context.Message.PictureDestinationKey; + context.Saga.AudioKey = context.Message.AudioDestinationKey; + }) + .TransitionTo(Processing)); - During(Failed, - Ignore(PictureProcessed), - Ignore(AudioProcessed), - Ignore(PictureFailed), - Ignore(AudioFailed)); + During(Processing, + When(PictureProcessed) + .Then(context => + { + context.Saga.PictureProcessed = true; + context.Saga.UpdatedAt = DateTime.UtcNow; + }) + .If(context => context.Saga.AudioProcessed, binder => binder.Finalize()), + When(AudioProcessed) + .Then(context => + { + context.Saga.AudioProcessed = true; + context.Saga.UpdatedAt = DateTime.UtcNow; + }) + .If(context => context.Saga.PictureProcessed, binder => binder.Finalize()), + When(PictureFailed) + .Then(context => context.Saga.UpdatedAt = DateTime.UtcNow) + .TransitionTo(Failed) + .Publish(context => new TrackProcessingFailed( + context.Saga.CorrelationId, + context.Saga.Bucket, + context.Saga.PictureKey, + context.Saga.AudioKey)), + When(AudioFailed) + .Then(context => context.Saga.UpdatedAt = DateTime.UtcNow) + .TransitionTo(Failed) + .Publish(context => new TrackProcessingFailed( + context.Saga.CorrelationId, + context.Saga.Bucket, + context.Saga.PictureKey, + context.Saga.AudioKey))); - SetCompletedWhenFinalized(); + During(Failed, + Ignore(PictureProcessed), + Ignore(AudioProcessed), + Ignore(PictureFailed), + Ignore(AudioFailed)); + + SetCompletedWhenFinalized(); + } } } diff --git a/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs index 2178791..b75d67b 100644 --- a/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs @@ -8,46 +8,47 @@ using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; -namespace Musify.Infrastructure.Observability; - -public static class OpenTelemetryDependencyInjection +namespace Musify.Infrastructure.Observability { - public static IServiceCollection AddObservability(this IServiceCollection services, IConfiguration configuration) + public static class OpenTelemetryDependencyInjection { - services.AddValidatedOptions(configuration); + public static IServiceCollection AddObservability(this IServiceCollection services, IConfiguration configuration) + { + services.AddValidatedOptions(configuration); - services - .AddOptions() - .Configure((exporterOptions, openTelemetryConfiguration) => - { - exporterOptions.Endpoint = new Uri(openTelemetryConfiguration.OtlpEndpoint); - }); + services + .AddOptions() + .Configure((exporterOptions, openTelemetryConfiguration) => + { + exporterOptions.Endpoint = new Uri(openTelemetryConfiguration.OtlpEndpoint); + }); - services - .AddOpenTelemetry() - .ConfigureResource(resource => - { - resource.AddService("Musify"); - }) - .WithTracing(tracing => - tracing.AddSource("MassTransit") - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddEntityFrameworkCoreInstrumentation() - .AddOtlpExporter() - ) - .WithMetrics(metrics => - metrics.AddMeter("MassTransit") - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation() - .AddProcessInstrumentation() - .AddOtlpExporter() - ) - .WithLogging(logging => - logging.AddOtlpExporter() - ); + services + .AddOpenTelemetry() + .ConfigureResource(resource => + { + resource.AddService("Musify"); + }) + .WithTracing(tracing => + tracing.AddSource("MassTransit") + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddEntityFrameworkCoreInstrumentation() + .AddOtlpExporter() + ) + .WithMetrics(metrics => + metrics.AddMeter("MassTransit") + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation() + .AddProcessInstrumentation() + .AddOtlpExporter() + ) + .WithLogging(logging => + logging.AddOtlpExporter() + ); - return services; + return services; + } } } diff --git a/backend/Core/Musify.Infrastructure/Persistence/AuditableEntityInterceptor.cs b/backend/Core/Musify.Infrastructure/Persistence/AuditableEntityInterceptor.cs index 38681b8..6a490c5 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/AuditableEntityInterceptor.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/AuditableEntityInterceptor.cs @@ -2,42 +2,43 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Musify.Domain.Abstractions; -namespace Musify.Infrastructure.Persistence; - -public sealed class AuditableEntityInterceptor : SaveChangesInterceptor +namespace Musify.Infrastructure.Persistence { - public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + public sealed class AuditableEntityInterceptor : SaveChangesInterceptor { - ApplyTimestamps(eventData.Context); - return base.SavingChanges(eventData, result); - } - - public override ValueTask> SavingChangesAsync( - DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) - { - ApplyTimestamps(eventData.Context); - return base.SavingChangesAsync(eventData, result, cancellationToken); - } - - private static void ApplyTimestamps(DbContext? context) - { - if (context == null) - return; - - context.ChangeTracker.DetectChanges(); - - var now = DateTime.UtcNow; - - foreach (var entry in context.ChangeTracker.Entries()) + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) { - if (entry.State == EntityState.Added) + ApplyTimestamps(eventData.Context); + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + ApplyTimestamps(eventData.Context); + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private static void ApplyTimestamps(DbContext? context) + { + if (context == null) + return; + + context.ChangeTracker.DetectChanges(); + + var now = DateTime.UtcNow; + + foreach (var entry in context.ChangeTracker.Entries()) { - entry.Entity.CreatedAt = now; - entry.Entity.UpdatedAt = now; - } - else if (entry.State == EntityState.Modified) - { - entry.Property(nameof(IAuditable.UpdatedAt)).CurrentValue = now; + if (entry.State == EntityState.Added) + { + entry.Entity.CreatedAt = now; + entry.Entity.UpdatedAt = now; + } + else if (entry.State == EntityState.Modified) + { + entry.Property(nameof(IAuditable.UpdatedAt)).CurrentValue = now; + } } } } diff --git a/backend/Core/Musify.Infrastructure/Persistence/Database.cs b/backend/Core/Musify.Infrastructure/Persistence/Database.cs index 929c3db..149ec10 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Database.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Database.cs @@ -8,81 +8,82 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.Configuration; using Npgsql; -namespace Musify.Infrastructure.Persistence; - -public class Database(DatabaseConfiguration configuration) - : DbContext, Musify.Application.Contracts.IDatabase +namespace Musify.Infrastructure.Persistence { - private static readonly AuditableEntityInterceptor AuditInterceptor = new(); - - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + public class Database(DatabaseConfiguration configuration) + : DbContext, Musify.Application.Contracts.IDatabase { - optionsBuilder - .UseNpgsql(configuration.ConnectionString, o => o.MapEnum("genre")) - .AddInterceptors(AuditInterceptor); - } + private static readonly AuditableEntityInterceptor AuditInterceptor = new(); - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - modelBuilder.AddInboxStateEntity(); - modelBuilder.AddOutboxMessageEntity(); - modelBuilder.AddOutboxStateEntity(); - - ModelConfiguration.Apply(modelBuilder); - } - - public DbSet Users => Set(); - - public DbSet Tracks => Set(); - - public DbSet PlayLists => Set(); - - public DbSet Albums => Set(); - - public DbSet AlbumHasTracks => Set(); - - public DbSet PlayListHasTracks => Set(); - - public DbSet Mixes => Set(); - - public DbSet MixItems => Set(); - - public DbSet UploadIntents => Set(); - - public DbSet ListeningHistories => Set(); - - public DbSet TrackLikes => Set(); - - public DbSet TrackTags => Set(); - - public DbSet UserFollows => Set(); - - public async Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) - { - var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); - return new DatabaseTransaction(transaction); - } - - public async Task> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken) - { - try + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { - await SaveChangesAsync(cancellationToken); - return Result.Success; + optionsBuilder + .UseNpgsql(configuration.ConnectionString, o => o.MapEnum("genre")) + .AddInterceptors(AuditInterceptor); } - catch (DbUpdateException exception) when (exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }) + + protected override void OnModelCreating(ModelBuilder modelBuilder) { - return onUniqueViolation; + base.OnModelCreating(modelBuilder); + + modelBuilder.AddInboxStateEntity(); + modelBuilder.AddOutboxMessageEntity(); + modelBuilder.AddOutboxStateEntity(); + + ModelConfiguration.Apply(modelBuilder); } - } - private sealed class DatabaseTransaction(IDbContextTransaction inner) : IDatabaseTransaction - { - public Task CommitAsync(CancellationToken cancellationToken) => - inner.CommitAsync(cancellationToken); + public DbSet Users => Set(); - public ValueTask DisposeAsync() => inner.DisposeAsync(); + public DbSet Tracks => Set(); + + public DbSet PlayLists => Set(); + + public DbSet Albums => Set(); + + public DbSet AlbumHasTracks => Set(); + + public DbSet PlayListHasTracks => Set(); + + public DbSet Mixes => Set(); + + public DbSet MixItems => Set(); + + public DbSet UploadIntents => Set(); + + public DbSet ListeningHistories => Set(); + + public DbSet TrackLikes => Set(); + + public DbSet TrackTags => Set(); + + public DbSet UserFollows => Set(); + + public async Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) + { + var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); + return new DatabaseTransaction(transaction); + } + + public async Task> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken) + { + try + { + await SaveChangesAsync(cancellationToken); + return Result.Success; + } + catch (DbUpdateException exception) when (exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }) + { + return onUniqueViolation; + } + } + + private sealed class DatabaseTransaction(IDbContextTransaction inner) : IDatabaseTransaction + { + public Task CommitAsync(CancellationToken cancellationToken) => + inner.CommitAsync(cancellationToken); + + public ValueTask DisposeAsync() => inner.DisposeAsync(); + } } } diff --git a/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs index 9ba10fe..7b28400 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs @@ -4,18 +4,19 @@ using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Infrastructure.Configuration; -namespace Musify.Infrastructure.Persistence; - -public static class DatabaseDependencyInjection +namespace Musify.Infrastructure.Persistence { - public static IServiceCollection AddDatabase(this IServiceCollection services, IConfiguration configuration) + public static class DatabaseDependencyInjection { - services.AddValidatedOptions(configuration); + public static IServiceCollection AddDatabase(this IServiceCollection services, IConfiguration configuration) + { + services.AddValidatedOptions(configuration); - services.AddDbContext(); + services.AddDbContext(); - services.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); + services.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); - return services; + return services; + } } } diff --git a/backend/Core/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs index b4e68ee..6b731c4 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs @@ -2,24 +2,25 @@ using Microsoft.EntityFrameworkCore.Design; using Microsoft.Extensions.Configuration; using Musify.Infrastructure.Configuration; -namespace Musify.Infrastructure.Persistence; - -public class DatabaseDesignTimeFactory : IDesignTimeDbContextFactory +namespace Musify.Infrastructure.Persistence { - public Database CreateDbContext(string[] args) + public class DatabaseDesignTimeFactory : IDesignTimeDbContextFactory { - var configuration = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("DesignSettings.json", optional: true) - .AddUserSecrets() - .AddEnvironmentVariables() - .Build(); + public Database CreateDbContext(string[] args) + { + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("DesignSettings.json", optional: true) + .AddUserSecrets() + .AddEnvironmentVariables() + .Build(); - var databaseConfiguration = configuration - .GetRequiredSection(DatabaseConfiguration.SectionName) - .Get() - ?? throw new InvalidOperationException($"{DatabaseConfiguration.SectionName} configuration section not found."); + var databaseConfiguration = configuration + .GetRequiredSection(DatabaseConfiguration.SectionName) + .Get() + ?? throw new InvalidOperationException($"{DatabaseConfiguration.SectionName} configuration section not found."); - return new Database(databaseConfiguration); + return new Database(databaseConfiguration); + } } } diff --git a/backend/Core/Musify.Infrastructure/Persistence/ModelConfiguration.cs b/backend/Core/Musify.Infrastructure/Persistence/ModelConfiguration.cs index 4e44ae1..dfeac50 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/ModelConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/ModelConfiguration.cs @@ -3,152 +3,153 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Sagas; -namespace Musify.Infrastructure.Persistence; - -/// -/// Entity model shared by and the in-memory test database, so both always agree. -/// -public static class ModelConfiguration +namespace Musify.Infrastructure.Persistence { - public static void Apply(ModelBuilder modelBuilder) + /// + /// Entity model shared by and the in-memory test database, so both always agree. + /// + public static class ModelConfiguration { - var trackProcessing = modelBuilder.Entity(); - trackProcessing.HasKey(state => state.CorrelationId); - trackProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); - trackProcessing.Property(state => state.CurrentState).HasMaxLength(64); + public static void Apply(ModelBuilder modelBuilder) + { + var trackProcessing = modelBuilder.Entity(); + trackProcessing.HasKey(state => state.CorrelationId); + trackProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); + trackProcessing.Property(state => state.CurrentState).HasMaxLength(64); - modelBuilder.Entity() - .OwnsOne(track => track.Pictures, pictures => - { - pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64); - pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64); - pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64); - pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64); - pictures.Property(p => p.ProcessingStatus).HasColumnName("PicturesProcessingStatus"); - }); - modelBuilder.Entity().Navigation(track => track.Pictures).IsRequired(); + modelBuilder.Entity() + .OwnsOne(track => track.Pictures, pictures => + { + pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64); + pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64); + pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64); + pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64); + pictures.Property(p => p.ProcessingStatus).HasColumnName("PicturesProcessingStatus"); + }); + modelBuilder.Entity().Navigation(track => track.Pictures).IsRequired(); - modelBuilder.Entity() - .OwnsOne(track => track.Audio, audio => - { - audio.Property(a => a.OriginalName).HasColumnName("OriginalAudioName").HasMaxLength(64); - audio.Property(a => a.FolderName).HasColumnName("AudioFolderName").HasMaxLength(64); - audio.Property(a => a.TranscodeStatus).HasColumnName("AudioTranscodeProcessingStatus"); - }); - modelBuilder.Entity().Navigation(track => track.Audio).IsRequired(); + modelBuilder.Entity() + .OwnsOne(track => track.Audio, audio => + { + audio.Property(a => a.OriginalName).HasColumnName("OriginalAudioName").HasMaxLength(64); + audio.Property(a => a.FolderName).HasColumnName("AudioFolderName").HasMaxLength(64); + audio.Property(a => a.TranscodeStatus).HasColumnName("AudioTranscodeProcessingStatus"); + }); + modelBuilder.Entity().Navigation(track => track.Audio).IsRequired(); - modelBuilder.Entity() - .HasOne(track => track.Owner) - .WithMany() - .HasForeignKey(track => track.OwnerUserId) - .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(track => track.Owner) + .WithMany() + .HasForeignKey(track => track.OwnerUserId) + .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity() - .Property(playList => playList.Visibility) - .HasDefaultValue(PlayListVisibility.Private); + modelBuilder.Entity() + .Property(playList => playList.Visibility) + .HasDefaultValue(PlayListVisibility.Private); - modelBuilder.Entity() - .OwnsOne(playList => playList.Pictures, pictures => - { - pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64).IsRequired(false); - pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64).IsRequired(false); - pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64).IsRequired(false); - pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64).IsRequired(false); - }); - modelBuilder.Entity().Navigation(playList => playList.Pictures).IsRequired(false); + modelBuilder.Entity() + .OwnsOne(playList => playList.Pictures, pictures => + { + pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64).IsRequired(false); + pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64).IsRequired(false); + pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64).IsRequired(false); + pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64).IsRequired(false); + }); + modelBuilder.Entity().Navigation(playList => playList.Pictures).IsRequired(false); - modelBuilder.Entity() - .OwnsOne(album => album.Pictures, pictures => - { - pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64); - pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64).IsRequired(false); - pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64).IsRequired(false); - pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64).IsRequired(false); - }); - modelBuilder.Entity().Navigation(album => album.Pictures).IsRequired(false); + modelBuilder.Entity() + .OwnsOne(album => album.Pictures, pictures => + { + pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64); + pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64).IsRequired(false); + pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64).IsRequired(false); + pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64).IsRequired(false); + }); + modelBuilder.Entity().Navigation(album => album.Pictures).IsRequired(false); - modelBuilder.Entity() - .HasOne(album => album.Owner) - .WithMany() - .HasForeignKey(album => album.OwnerUserId) - .OnDelete(DeleteBehavior.Restrict); + 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() + .HasOne(albumTrack => albumTrack.Album) + .WithMany(album => album.AlbumTracks) + .HasForeignKey(albumTrack => albumTrack.AlbumId) + .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() - .HasOne(albumTrack => albumTrack.Track) - .WithMany(track => track.AlbumTracks) - .HasForeignKey(albumTrack => albumTrack.TrackId) - .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(albumTrack => albumTrack.Track) + .WithMany(track => track.AlbumTracks) + .HasForeignKey(albumTrack => albumTrack.TrackId) + .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() - .HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId }) - .IsUnique(); + modelBuilder.Entity() + .HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId }) + .IsUnique(); - modelBuilder.Entity() - .HasIndex(playListTrack => new { playListTrack.PlayListId, playListTrack.TrackId }) - .IsUnique(); + modelBuilder.Entity() + .HasIndex(playListTrack => new { playListTrack.PlayListId, playListTrack.TrackId }) + .IsUnique(); - modelBuilder.Entity() - .HasIndex(mix => new { mix.UserId, mix.Position }); + modelBuilder.Entity() + .HasIndex(mix => new { mix.UserId, mix.Position }); - modelBuilder.Entity() - .HasOne(item => item.Mix) - .WithMany(mix => mix.Items) - .HasForeignKey(item => item.MixId) - .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(item => item.Mix) + .WithMany(mix => mix.Items) + .HasForeignKey(item => item.MixId) + .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() - .HasOne(item => item.Track) - .WithMany() - .HasForeignKey(item => item.TrackId) - .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(item => item.Track) + .WithMany() + .HasForeignKey(item => item.TrackId) + .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() - .HasIndex(item => new { item.MixId, item.Position }); + modelBuilder.Entity() + .HasIndex(item => new { item.MixId, item.Position }); - modelBuilder.Entity() - .HasIndex(like => new { like.UserId, like.TrackId }) - .IsUnique(); + modelBuilder.Entity() + .HasIndex(like => new { like.UserId, like.TrackId }) + .IsUnique(); - modelBuilder.Entity() - .HasOne(trackTag => trackTag.Track) - .WithMany(track => track.Tags) - .HasForeignKey(trackTag => trackTag.TrackId) - .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(trackTag => trackTag.Track) + .WithMany(track => track.Tags) + .HasForeignKey(trackTag => trackTag.TrackId) + .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() - .HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag }) - .IsUnique(); + modelBuilder.Entity() + .HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag }) + .IsUnique(); - modelBuilder.Entity() - .HasOne(follow => follow.Follower) - .WithMany() - .HasForeignKey(follow => follow.FollowerId) - .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(follow => follow.Follower) + .WithMany() + .HasForeignKey(follow => follow.FollowerId) + .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity() - .HasOne(follow => follow.Followed) - .WithMany() - .HasForeignKey(follow => follow.FollowedId) - .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(follow => follow.Followed) + .WithMany() + .HasForeignKey(follow => follow.FollowedId) + .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity() - .HasIndex(follow => new { follow.FollowerId, follow.FollowedId }) - .IsUnique(); + modelBuilder.Entity() + .HasIndex(follow => new { follow.FollowerId, follow.FollowedId }) + .IsUnique(); - var playListProcessing = modelBuilder.Entity(); - playListProcessing.HasKey(state => state.CorrelationId); - playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); - playListProcessing.Property(state => state.CurrentState).HasMaxLength(64); + var playListProcessing = modelBuilder.Entity(); + playListProcessing.HasKey(state => state.CorrelationId); + playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); + playListProcessing.Property(state => state.CurrentState).HasMaxLength(64); - var albumProcessing = modelBuilder.Entity(); - albumProcessing.HasKey(state => state.CorrelationId); - albumProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); - albumProcessing.Property(state => state.CurrentState).HasMaxLength(64); + var albumProcessing = modelBuilder.Entity(); + albumProcessing.HasKey(state => state.CorrelationId); + albumProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); + albumProcessing.Property(state => state.CurrentState).HasMaxLength(64); + } } } diff --git a/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs b/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs index c671eb3..a83ddd1 100644 --- a/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs +++ b/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs @@ -5,171 +5,172 @@ using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Infrastructure.Configuration; -namespace Musify.Infrastructure.Services; - -public sealed partial class AudioTranscoderService( - ILogger logger, - AudioConfiguration audioConfiguration) - : IAudioTranscoderService +namespace Musify.Infrastructure.Services { - private sealed record FfmpegExecutionResult(int ExitCode, string StandardOutput, string StandardError); - - [GeneratedRegex(@"Duration:\s*(\d+):(\d{2}):(\d{2}(?:\.\d+)?)", RegexOptions.IgnoreCase)] - private static partial Regex DurationRegex(); - - public async Task TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken) + public sealed partial class AudioTranscoderService( + ILogger logger, + AudioConfiguration audioConfiguration) + : IAudioTranscoderService { - Directory.CreateDirectory(destinationPath); + private sealed record FfmpegExecutionResult(int ExitCode, string StandardOutput, string StandardError); - logger.LogInformation("Starting audio transcoding for {DestinationPath}", destinationPath); + [GeneratedRegex(@"Duration:\s*(\d+):(\d{2}):(\d{2}(?:\.\d+)?)", RegexOptions.IgnoreCase)] + private static partial Regex DurationRegex(); - var executionResult = await ExecuteFfmpegAsync( - BuildAudioFileArguments(destinationPath, audioConfiguration.Ffmpeg), - destinationPath, - audioStream, - "TranscodeToAudioFile", - cancellationToken); - - if (executionResult.ExitCode != 0) + public async Task TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken) { - logger.LogError("Audio transcoding failed with exit code {ExitCode}. Output: {StandardOutput}, Error: {StandardError}", - executionResult.ExitCode, executionResult.StandardOutput, executionResult.StandardError); - return new AudioTranscodeResult(executionResult.ExitCode, TimeSpan.Zero); - } + Directory.CreateDirectory(destinationPath); - logger.LogInformation("Audio transcoding completed for {DestinationPath}", destinationPath); + logger.LogInformation("Starting audio transcoding for {DestinationPath}", destinationPath); - var outputPath = Path.Combine(destinationPath, audioConfiguration.Ffmpeg.OutputFileName); - var duration = await GetAudioDurationAsync(outputPath, cancellationToken); - return new AudioTranscodeResult(executionResult.ExitCode, duration); - } + var executionResult = await ExecuteFfmpegAsync( + BuildAudioFileArguments(destinationPath, audioConfiguration.Ffmpeg), + destinationPath, + audioStream, + "TranscodeToAudioFile", + cancellationToken); - private async Task GetAudioDurationAsync(string filePath, CancellationToken cancellationToken) - { - var executionResult = await ExecuteFfmpegAsync( - BuildProbeDurationArguments(filePath), - Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory, - input: null, - "ProbeAudioDuration", - cancellationToken); - - var match = DurationRegex().Match(executionResult.StandardError); - if (!match.Success) - throw new InvalidOperationException($"Could not determine audio duration for {filePath}."); - - var hours = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); - var minutes = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture); - var seconds = double.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture); - - return new TimeSpan(hours, minutes, 0) + TimeSpan.FromSeconds(seconds); - } - - private async Task ExecuteFfmpegAsync( - string arguments, - string workingDirectory, - Stream? input, - string operationName, - CancellationToken cancellationToken) - { - using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: input != null); - process.Start(); - - logger.LogDebug("[{Operation}] ffmpeg started (PID: {ProcessId}) in {WorkingDirectory}", operationName, process.Id, workingDirectory); - - var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - - if (input != null) - { - if (input.CanSeek) - input.Position = 0; - - await input.CopyToAsync(process.StandardInput.BaseStream, cancellationToken); - process.StandardInput.Close(); - } - - return await WaitForProcessResultAsync(process, outputTask, errorTask, operationName, cancellationToken); - } - - private Process BuildProcess(string arguments, string workingDirectory, bool redirectStandardInput) - => new() - { - StartInfo = new ProcessStartInfo + if (executionResult.ExitCode != 0) { - FileName = audioConfiguration.Ffmpeg.ExecutableName, - WorkingDirectory = workingDirectory, - Arguments = arguments, - RedirectStandardInput = redirectStandardInput, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, + logger.LogError("Audio transcoding failed with exit code {ExitCode}. Output: {StandardOutput}, Error: {StandardError}", + executionResult.ExitCode, executionResult.StandardOutput, executionResult.StandardError); + return new AudioTranscodeResult(executionResult.ExitCode, TimeSpan.Zero); } - }; - private async Task WaitForProcessResultAsync( - Process process, - Task outputTask, - Task errorTask, - string operationName, - CancellationToken cancellationToken) - { - using var linkedCancellationTokens = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - linkedCancellationTokens.CancelAfter(audioConfiguration.TranscodingTimeout); + logger.LogInformation("Audio transcoding completed for {DestinationPath}", destinationPath); - try - { - await process.WaitForExitAsync(linkedCancellationTokens.Token); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - TryKillProcess(process); - throw new TimeoutException($"ffmpeg execution exceeded timeout {audioConfiguration.TranscodingTimeout} (PID: {process.Id})."); + var outputPath = Path.Combine(destinationPath, audioConfiguration.Ffmpeg.OutputFileName); + var duration = await GetAudioDurationAsync(outputPath, cancellationToken); + return new AudioTranscodeResult(executionResult.ExitCode, duration); } - var standardError = await errorTask; - var standardOutput = await outputTask; + private async Task GetAudioDurationAsync(string filePath, CancellationToken cancellationToken) + { + var executionResult = await ExecuteFfmpegAsync( + BuildProbeDurationArguments(filePath), + Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory, + input: null, + "ProbeAudioDuration", + cancellationToken); - logger.LogDebug("[{Operation}] ffmpeg finished (PID: {ProcessId}). Exit code: {ExitCode}, Output: {OutputLength}B, Error: {ErrorLength}B", - operationName, process.Id, process.ExitCode, standardOutput.Length, standardError.Length); + var match = DurationRegex().Match(executionResult.StandardError); + if (!match.Success) + throw new InvalidOperationException($"Could not determine audio duration for {filePath}."); - return new FfmpegExecutionResult(process.ExitCode, standardOutput, standardError); + var hours = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); + var minutes = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture); + var seconds = double.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture); + + return new TimeSpan(hours, minutes, 0) + TimeSpan.FromSeconds(seconds); + } + + private async Task ExecuteFfmpegAsync( + string arguments, + string workingDirectory, + Stream? input, + string operationName, + CancellationToken cancellationToken) + { + using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: input != null); + process.Start(); + + logger.LogDebug("[{Operation}] ffmpeg started (PID: {ProcessId}) in {WorkingDirectory}", operationName, process.Id, workingDirectory); + + var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + + if (input != null) + { + if (input.CanSeek) + input.Position = 0; + + await input.CopyToAsync(process.StandardInput.BaseStream, cancellationToken); + process.StandardInput.Close(); + } + + return await WaitForProcessResultAsync(process, outputTask, errorTask, operationName, cancellationToken); + } + + private Process BuildProcess(string arguments, string workingDirectory, bool redirectStandardInput) + => new() + { + StartInfo = new ProcessStartInfo + { + FileName = audioConfiguration.Ffmpeg.ExecutableName, + WorkingDirectory = workingDirectory, + Arguments = arguments, + RedirectStandardInput = redirectStandardInput, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + } + }; + + private async Task WaitForProcessResultAsync( + Process process, + Task outputTask, + Task errorTask, + string operationName, + CancellationToken cancellationToken) + { + using var linkedCancellationTokens = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + linkedCancellationTokens.CancelAfter(audioConfiguration.TranscodingTimeout); + + try + { + await process.WaitForExitAsync(linkedCancellationTokens.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + TryKillProcess(process); + throw new TimeoutException($"ffmpeg execution exceeded timeout {audioConfiguration.TranscodingTimeout} (PID: {process.Id})."); + } + + var standardError = await errorTask; + var standardOutput = await outputTask; + + logger.LogDebug("[{Operation}] ffmpeg finished (PID: {ProcessId}). Exit code: {ExitCode}, Output: {OutputLength}B, Error: {ErrorLength}B", + operationName, process.Id, process.ExitCode, standardOutput.Length, standardError.Length); + + return new FfmpegExecutionResult(process.ExitCode, standardOutput, standardError); + } + + private void TryKillProcess(Process process) + { + try + { + if (!process.HasExited) + process.Kill(); + } + catch (InvalidOperationException exception) + { + logger.LogWarning(exception, "Could not kill ffmpeg process (PID: {ProcessId}); it had already exited", process.Id); + } + } + + private static string BuildAudioFileArguments(string outputDirectory, FfmpegConfiguration ffmpegConfiguration) + { + var outputPath = Path.Combine(outputDirectory, ffmpegConfiguration.OutputFileName); + var additionalArguments = string.IsNullOrWhiteSpace(ffmpegConfiguration.AdditionalArguments) + ? string.Empty + : $" {ffmpegConfiguration.AdditionalArguments.Trim()}"; + + return "-y -hide_banner -loglevel error " + + "-i pipe:0 " + + "-vn " + + "-map 0:a " + + $"-c:a {ffmpegConfiguration.AudioCodec} " + + $"-b:a {ffmpegConfiguration.AudioBitrate} " + + $"-ac {ffmpegConfiguration.AudioChannels} " + + $"-ar {ffmpegConfiguration.AudioSampleRate} " + + $"-profile:a {ffmpegConfiguration.AudioProfile} " + + "-movflags +faststart" + + additionalArguments + + $" \"{outputPath}\""; + } + + private static string BuildProbeDurationArguments(string filePath) + => $"-hide_banner -i \"{filePath}\" -f null -"; } - - private void TryKillProcess(Process process) - { - try - { - if (!process.HasExited) - process.Kill(); - } - catch (InvalidOperationException exception) - { - logger.LogWarning(exception, "Could not kill ffmpeg process (PID: {ProcessId}); it had already exited", process.Id); - } - } - - private static string BuildAudioFileArguments(string outputDirectory, FfmpegConfiguration ffmpegConfiguration) - { - var outputPath = Path.Combine(outputDirectory, ffmpegConfiguration.OutputFileName); - var additionalArguments = string.IsNullOrWhiteSpace(ffmpegConfiguration.AdditionalArguments) - ? string.Empty - : $" {ffmpegConfiguration.AdditionalArguments.Trim()}"; - - return "-y -hide_banner -loglevel error " + - "-i pipe:0 " + - "-vn " + - "-map 0:a " + - $"-c:a {ffmpegConfiguration.AudioCodec} " + - $"-b:a {ffmpegConfiguration.AudioBitrate} " + - $"-ac {ffmpegConfiguration.AudioChannels} " + - $"-ar {ffmpegConfiguration.AudioSampleRate} " + - $"-profile:a {ffmpegConfiguration.AudioProfile} " + - "-movflags +faststart" + - additionalArguments + - $" \"{outputPath}\""; - } - - private static string BuildProbeDurationArguments(string filePath) - => $"-hide_banner -i \"{filePath}\" -f null -"; } diff --git a/backend/Core/Musify.Infrastructure/Services/PictureService.cs b/backend/Core/Musify.Infrastructure/Services/PictureService.cs index e938bb8..fff427d 100644 --- a/backend/Core/Musify.Infrastructure/Services/PictureService.cs +++ b/backend/Core/Musify.Infrastructure/Services/PictureService.cs @@ -3,29 +3,30 @@ using Musify.Application.Contracts; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Processing; -namespace Musify.Infrastructure.Services; - -public class PictureService(ILogger logger) - : IPictureService +namespace Musify.Infrastructure.Services { - public async Task ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken) + public class PictureService(ILogger logger) + : IPictureService { - logger.LogDebug("Resizing picture to {Width}x{Height}", width, height); - if (pictureStream.CanSeek && pictureStream.Position > 0) - pictureStream.Position = 0; - - using var picture = await Image.LoadAsync(pictureStream, cancellationToken); - picture.Mutate(options => options.Resize(new ResizeOptions + public async Task ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken) { - Size = new Size(width, height), - Mode = ResizeMode.Max - })); + logger.LogDebug("Resizing picture to {Width}x{Height}", width, height); + if (pictureStream.CanSeek && pictureStream.Position > 0) + pictureStream.Position = 0; - var memoryStream = new MemoryStream(); - await picture.SaveAsWebpAsync(memoryStream, cancellationToken); - memoryStream.Position = 0; + using var picture = await Image.LoadAsync(pictureStream, cancellationToken); + picture.Mutate(options => options.Resize(new ResizeOptions + { + Size = new Size(width, height), + Mode = ResizeMode.Max + })); - logger.LogDebug("Picture resized to {Width}x{Height}, length: {Length}", width, height, memoryStream.Length); - return memoryStream; + var memoryStream = new MemoryStream(); + await picture.SaveAsWebpAsync(memoryStream, cancellationToken); + memoryStream.Position = 0; + + logger.LogDebug("Picture resized to {Width}x{Height}, length: {Length}", width, height, memoryStream.Length); + return memoryStream; + } } } diff --git a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs index 47e9c43..9c07da3 100644 --- a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs @@ -8,68 +8,69 @@ using Musify.Application.Contracts; using Musify.Infrastructure.Configuration; using Musify.Infrastructure.Jobs; -namespace Musify.Infrastructure.Services; - -public static class ServicesDependencyInjection +namespace Musify.Infrastructure.Services { - public static IServiceCollection AddStorageService(this IServiceCollection services, IConfiguration configuration) + public static class ServicesDependencyInjection { - services.AddValidatedOptions(configuration); - - services.AddSingleton(serviceProvider => + public static IServiceCollection AddStorageService(this IServiceCollection services, IConfiguration configuration) { - var configuration = serviceProvider.GetRequiredService(); - var s3Configuration = new AmazonS3Config + services.AddValidatedOptions(configuration); + + services.AddSingleton(serviceProvider => { - ServiceURL = configuration.Address, - ForcePathStyle = configuration.ForcePathStyle, - UseHttp = configuration.UseHttp, - }; - return new AmazonS3Client(configuration.AccessKey, configuration.SecretAccessKey, s3Configuration); - }); + var configuration = serviceProvider.GetRequiredService(); + var s3Configuration = new AmazonS3Config + { + ServiceURL = configuration.Address, + ForcePathStyle = configuration.ForcePathStyle, + UseHttp = configuration.UseHttp, + }; + return new AmazonS3Client(configuration.AccessKey, configuration.SecretAccessKey, s3Configuration); + }); - services.AddSingleton(); - return services; - } + services.AddSingleton(); + return services; + } - public static IServiceCollection AddPictureService(this IServiceCollection services) - { - services.AddScoped(); - return services; - } - - public static IServiceCollection AddJobs(this IServiceCollection services) - { - services.AddHangfire((serviceProvider, hangfireConfiguration) => + public static IServiceCollection AddPictureService(this IServiceCollection services) { - var databaseConfiguration = serviceProvider.GetRequiredService(); - hangfireConfiguration.UsePostgreSqlStorage(options => - options.UseNpgsqlConnection(databaseConfiguration.ConnectionString)); - }); + services.AddScoped(); + return services; + } - services.AddHangfireServer(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddHostedService(); + public static IServiceCollection AddJobs(this IServiceCollection services) + { + services.AddHangfire((serviceProvider, hangfireConfiguration) => + { + var databaseConfiguration = serviceProvider.GetRequiredService(); + hangfireConfiguration.UsePostgreSqlStorage(options => + options.UseNpgsqlConnection(databaseConfiguration.ConnectionString)); + }); - return services; - } + services.AddHangfireServer(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddHostedService(); - public static IServiceCollection AddStreamTicketService(this IServiceCollection services, IConfiguration configuration) - { - services.AddValidatedOptions(configuration); + return services; + } - services.AddSingleton(); - return services; - } + public static IServiceCollection AddStreamTicketService(this IServiceCollection services, IConfiguration configuration) + { + services.AddValidatedOptions(configuration); - public static IServiceCollection AddAudioTranscoder(this IServiceCollection services, IConfiguration configuration) - { - services.AddValidatedOptions(configuration); + services.AddSingleton(); + return services; + } - services.AddScoped(); - return services; + public static IServiceCollection AddAudioTranscoder(this IServiceCollection services, IConfiguration configuration) + { + services.AddValidatedOptions(configuration); + + services.AddScoped(); + return services; + } } } diff --git a/backend/Core/Musify.Infrastructure/Services/StorageService.cs b/backend/Core/Musify.Infrastructure/Services/StorageService.cs index a44fb6c..5284297 100644 --- a/backend/Core/Musify.Infrastructure/Services/StorageService.cs +++ b/backend/Core/Musify.Infrastructure/Services/StorageService.cs @@ -8,153 +8,154 @@ using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Infrastructure.Configuration; -namespace Musify.Infrastructure.Services; - -public sealed class StorageService( - IAmazonS3 amazonS3, - ILogger logger, - InfrastructureStorageConfiguration storageClientConfiguration) - : IStorageService +namespace Musify.Infrastructure.Services { - public async Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken) + public sealed class StorageService( + IAmazonS3 amazonS3, + ILogger logger, + InfrastructureStorageConfiguration storageClientConfiguration) + : IStorageService { - try + public async Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken) { - var response = await amazonS3.GetObjectAsync( - new GetObjectRequest { BucketName = bucket, Key = key }, cancellationToken); - logger.LogDebug("Retrieved file from S3 {Bucket}/{Key}", bucket, key); - return response.ResponseStream; + try + { + var response = await amazonS3.GetObjectAsync( + new GetObjectRequest { BucketName = bucket, Key = key }, cancellationToken); + logger.LogDebug("Retrieved file from S3 {Bucket}/{Key}", bucket, key); + return response.ResponseStream; + } + catch (AmazonS3Exception exception) when (exception.ErrorCode == "NoSuchKey") + { + logger.LogDebug("File not found in S3 {Bucket}/{Key}", bucket, key); + return null; + } } - catch (AmazonS3Exception exception) when (exception.ErrorCode == "NoSuchKey") + + public async Task GetUploadUrlAsync( + string bucket, + string key, + string contentType, + TimeSpan expirationTime, + bool preventOverwrite = true, + CancellationToken cancellationToken = default) { - logger.LogDebug("File not found in S3 {Bucket}/{Key}", bucket, key); - return null; + var request = new GetPreSignedUrlRequest + { + BucketName = bucket, + Key = key, + Expires = DateTime.UtcNow + expirationTime, + Protocol = storageClientConfiguration.UseHttp ? Protocol.HTTP : Protocol.HTTPS, + Verb = HttpVerb.PUT, + ContentType = contentType + }; + + if (preventOverwrite) + request.Headers["If-None-Match"] = "*"; + + logger.LogDebug("Generating pre-signed upload URL for {Bucket}/{Key}", bucket, key); + return await amazonS3.GetPreSignedURLAsync(request); } - } - public async Task GetUploadUrlAsync( - string bucket, - string key, - string contentType, - TimeSpan expirationTime, - bool preventOverwrite = true, - CancellationToken cancellationToken = default) - { - var request = new GetPreSignedUrlRequest + public async Task HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken) { - BucketName = bucket, - Key = key, - Expires = DateTime.UtcNow + expirationTime, - Protocol = storageClientConfiguration.UseHttp ? Protocol.HTTP : Protocol.HTTPS, - Verb = HttpVerb.PUT, - ContentType = contentType - }; - - if (preventOverwrite) - request.Headers["If-None-Match"] = "*"; - - logger.LogDebug("Generating pre-signed upload URL for {Bucket}/{Key}", bucket, key); - return await amazonS3.GetPreSignedURLAsync(request); - } - - public async Task HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken) - { - try - { - var response = await amazonS3.GetObjectMetadataAsync(bucket, key, cancellationToken); - return new ObjectMetaData(response.Headers.ContentType, response.ContentLength); + try + { + var response = await amazonS3.GetObjectMetadataAsync(bucket, key, cancellationToken); + return new ObjectMetaData(response.Headers.ContentType, response.ContentLength); + } + catch (AmazonS3Exception exception) when (exception.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } } - catch (AmazonS3Exception exception) when (exception.StatusCode == System.Net.HttpStatusCode.NotFound) + + public async Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken) { - return null; + logger.LogDebug("Removing file from S3 {Bucket}/{Key}", bucket, key); + await amazonS3.DeleteObjectAsync(new DeleteObjectRequest { BucketName = bucket, Key = key }, cancellationToken); } - } - public async Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken) - { - logger.LogDebug("Removing file from S3 {Bucket}/{Key}", bucket, key); - await amazonS3.DeleteObjectAsync(new DeleteObjectRequest { BucketName = bucket, Key = key }, cancellationToken); - } - - public async Task> TransferFilesAsync( - string sourceDirectory, string bucket, string route, CancellationToken cancellationToken) - { - using var transferUtility = new TransferUtility(amazonS3); - - var uploadedKeys = new List(); - - foreach (var filePath in Directory.GetFiles(sourceDirectory, "*")) + public async Task> TransferFilesAsync( + string sourceDirectory, string bucket, string route, CancellationToken cancellationToken) { - var key = StorageKey.Combine(route, Path.GetFileName(filePath)); + using var transferUtility = new TransferUtility(amazonS3); - await transferUtility.UploadAsync( - new TransferUtilityUploadRequest + var uploadedKeys = new List(); + + foreach (var filePath in Directory.GetFiles(sourceDirectory, "*")) + { + var key = StorageKey.Combine(route, Path.GetFileName(filePath)); + + await transferUtility.UploadAsync( + new TransferUtilityUploadRequest + { + FilePath = filePath, + BucketName = bucket, + Key = key, + ContentType = MimeUtility.GetMimeMapping(filePath) + }, + cancellationToken); + + uploadedKeys.Add(key); + logger.LogDebug("Transferred file to S3 {Bucket}/{Key}", bucket, key); + } + + logger.LogInformation("Transferred {Count} files to {Bucket}/{Route}", uploadedKeys.Count, bucket, route); + + return uploadedKeys; + } + + public async Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken) + { + await using var fileStream = File.OpenRead(filePath); + await UploadFileAsync(fileStream, MimeUtility.GetMimeMapping(filePath), bucket, key, cancellationToken); + } + + public async Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken) + { + await amazonS3.PutObjectAsync( + new PutObjectRequest { BucketName = bucket, ContentType = contentType, InputStream = sourceStream, Key = key }, + cancellationToken); + logger.LogDebug("Uploaded file to S3 {Bucket}/{Key}", bucket, key); + } + + public async Task CopyFileAsync( + string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, CancellationToken cancellationToken) + { + await amazonS3.CopyObjectAsync( + new CopyObjectRequest { - FilePath = filePath, - BucketName = bucket, - Key = key, - ContentType = MimeUtility.GetMimeMapping(filePath) + SourceBucket = sourceBucket, + SourceKey = sourceKey, + DestinationBucket = destinationBucket, + DestinationKey = destinationKey }, cancellationToken); - uploadedKeys.Add(key); - logger.LogDebug("Transferred file to S3 {Bucket}/{Key}", bucket, key); + logger.LogDebug("Copied file from {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}", + sourceBucket, sourceKey, destinationBucket, destinationKey); } - logger.LogInformation("Transferred {Count} files to {Bucket}/{Route}", uploadedKeys.Count, bucket, route); - - return uploadedKeys; - } - - public async Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken) - { - await using var fileStream = File.OpenRead(filePath); - await UploadFileAsync(fileStream, MimeUtility.GetMimeMapping(filePath), bucket, key, cancellationToken); - } - - public async Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken) - { - await amazonS3.PutObjectAsync( - new PutObjectRequest { BucketName = bucket, ContentType = contentType, InputStream = sourceStream, Key = key }, - cancellationToken); - logger.LogDebug("Uploaded file to S3 {Bucket}/{Key}", bucket, key); - } - - public async Task CopyFileAsync( - string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, CancellationToken cancellationToken) - { - await amazonS3.CopyObjectAsync( - new CopyObjectRequest + public async IAsyncEnumerable ListObjectsAsync( + string bucket, string prefix, [EnumeratorCancellation] CancellationToken cancellationToken) + { + var listRequest = new ListObjectsV2Request { - SourceBucket = sourceBucket, - SourceKey = sourceKey, - DestinationBucket = destinationBucket, - DestinationKey = destinationKey - }, - cancellationToken); + BucketName = bucket, + Prefix = prefix.EndsWith('/') ? prefix : $"{prefix}/" + }; - logger.LogDebug("Copied file from {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}", - sourceBucket, sourceKey, destinationBucket, destinationKey); - } + ListObjectsV2Response listResponse; + do + { + listResponse = await amazonS3.ListObjectsV2Async(listRequest, cancellationToken); + foreach (var s3Object in listResponse.S3Objects ?? []) + yield return new StorageObject(s3Object.Key, (s3Object.LastModified ?? DateTime.UtcNow).ToUniversalTime()); - public async IAsyncEnumerable ListObjectsAsync( - string bucket, string prefix, [EnumeratorCancellation] CancellationToken cancellationToken) - { - var listRequest = new ListObjectsV2Request - { - BucketName = bucket, - Prefix = prefix.EndsWith('/') ? prefix : $"{prefix}/" - }; - - ListObjectsV2Response listResponse; - do - { - listResponse = await amazonS3.ListObjectsV2Async(listRequest, cancellationToken); - foreach (var s3Object in listResponse.S3Objects ?? []) - yield return new StorageObject(s3Object.Key, (s3Object.LastModified ?? DateTime.UtcNow).ToUniversalTime()); - - listRequest.ContinuationToken = listResponse.NextContinuationToken; + listRequest.ContinuationToken = listResponse.NextContinuationToken; + } + while (listResponse.IsTruncated == true); } - while (listResponse.IsTruncated == true); } } diff --git a/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs b/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs index f52e92a..575fad7 100644 --- a/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs +++ b/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs @@ -5,50 +5,51 @@ using Microsoft.IdentityModel.Tokens; using Musify.Application.Contracts; using Musify.Infrastructure.Configuration; -namespace Musify.Infrastructure.Services; - -public sealed class StreamTicketService : IStreamTicketService, IDisposable +namespace Musify.Infrastructure.Services { - private readonly StreamTicketConfiguration configuration; - private readonly RSA rsa; - private readonly SigningCredentials signingCredentials; - private readonly JsonWebTokenHandler tokenHandler = new(); - - public StreamTicketService(StreamTicketConfiguration configuration) + public sealed class StreamTicketService : IStreamTicketService, IDisposable { - this.configuration = configuration; + private readonly StreamTicketConfiguration configuration; + private readonly RSA rsa; + private readonly SigningCredentials signingCredentials; + private readonly JsonWebTokenHandler tokenHandler = new(); - rsa = RSA.Create(); - rsa.ImportFromPem(File.ReadAllText(configuration.PrivateKeyPath)); - - var key = new RsaSecurityKey(rsa); - signingCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256); - } - - public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null) - { - var now = DateTimeOffset.UtcNow; - - var claims = new Dictionary { ["prefix"] = keyPrefix }; - if (userId != null) - claims["sub"] = userId.Value.ToString(CultureInfo.InvariantCulture); - if (maxBytes != null) - claims["maxBytes"] = maxBytes.Value.ToString(CultureInfo.InvariantCulture); - - var descriptor = new SecurityTokenDescriptor + public StreamTicketService(StreamTicketConfiguration configuration) { - Issuer = configuration.Issuer, - Audience = configuration.Audience, - IssuedAt = now.UtcDateTime, - NotBefore = now.UtcDateTime, - Expires = now.AddSeconds(configuration.TicketTtlSeconds).UtcDateTime, - Claims = claims, - SigningCredentials = signingCredentials, - }; + this.configuration = configuration; - var token = tokenHandler.CreateToken(descriptor); - return new StreamTicket(token, configuration.TicketTtlSeconds); + rsa = RSA.Create(); + rsa.ImportFromPem(File.ReadAllText(configuration.PrivateKeyPath)); + + var key = new RsaSecurityKey(rsa); + signingCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256); + } + + public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null) + { + var now = DateTimeOffset.UtcNow; + + var claims = new Dictionary { ["prefix"] = keyPrefix }; + if (userId != null) + claims["sub"] = userId.Value.ToString(CultureInfo.InvariantCulture); + if (maxBytes != null) + claims["maxBytes"] = maxBytes.Value.ToString(CultureInfo.InvariantCulture); + + var descriptor = new SecurityTokenDescriptor + { + Issuer = configuration.Issuer, + Audience = configuration.Audience, + IssuedAt = now.UtcDateTime, + NotBefore = now.UtcDateTime, + Expires = now.AddSeconds(configuration.TicketTtlSeconds).UtcDateTime, + Claims = claims, + SigningCredentials = signingCredentials, + }; + + var token = tokenHandler.CreateToken(descriptor); + return new StreamTicket(token, configuration.TicketTtlSeconds); + } + + public void Dispose() => rsa.Dispose(); } - - public void Dispose() => rsa.Dispose(); } diff --git a/backend/Hosts/Musify.Api/Authentication/AuthenticationConfiguration.cs b/backend/Hosts/Musify.Api/Authentication/AuthenticationConfiguration.cs index f7196c1..8c3f4c1 100644 --- a/backend/Hosts/Musify.Api/Authentication/AuthenticationConfiguration.cs +++ b/backend/Hosts/Musify.Api/Authentication/AuthenticationConfiguration.cs @@ -1,40 +1,41 @@ using System.ComponentModel.DataAnnotations; using Musify.Application.Configuration; -namespace Musify.Api.Authentication; - -public sealed class AuthenticationConfiguration : IConfigurationOptions +namespace Musify.Api.Authentication { - public static string SectionName => "Authentication"; + public sealed class AuthenticationConfiguration : IConfigurationOptions + { + public static string SectionName => "Authentication"; - [Required] - [Url] - public required string MetadataAddress { get; set; } + [Required] + [Url] + public required string MetadataAddress { get; set; } - [Required] - public required string IssuerAddress { get; set; } + [Required] + public required string IssuerAddress { get; set; } - [Required] - public required string AudienceAddress { get; set; } + [Required] + public required string AudienceAddress { get; set; } - [Required] - public required string ClientId { get; set; } + [Required] + public required string ClientId { get; set; } - public string? ClientSecret { get; set; } + public string? ClientSecret { get; set; } - [Required] - [Url] - public required string AuthorizationEndpoint { get; set; } + [Required] + [Url] + public required string AuthorizationEndpoint { get; set; } - [Required] - [Url] - public required string TokenEndpoint { get; set; } + [Required] + [Url] + public required string TokenEndpoint { get; set; } - [Required] - [MinLength(1)] - public required string[] Scopes { get; set; } + [Required] + [MinLength(1)] + public required string[] Scopes { get; set; } - public string? ScalarRedirectUri { get; set; } + public string? ScalarRedirectUri { get; set; } - public bool RequireHttpsMetadata { get; set; } = true; + public bool RequireHttpsMetadata { get; set; } = true; + } } diff --git a/backend/Hosts/Musify.Api/Authentication/AuthenticationExtensions.cs b/backend/Hosts/Musify.Api/Authentication/AuthenticationExtensions.cs index 6052f50..7cc0362 100644 --- a/backend/Hosts/Musify.Api/Authentication/AuthenticationExtensions.cs +++ b/backend/Hosts/Musify.Api/Authentication/AuthenticationExtensions.cs @@ -1,27 +1,28 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Musify.Application.Configuration; -namespace Musify.Api.Authentication; - -public static class AuthenticationExtensions +namespace Musify.Api.Authentication { - public static IServiceCollection AddAuthenticationConfiguration( - this IServiceCollection services, - IConfiguration configuration) + public static class AuthenticationExtensions { - services.AddValidatedOptions(configuration); + public static IServiceCollection AddAuthenticationConfiguration( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddValidatedOptions(configuration); - services.AddMemoryCache(); - services.AddHttpClient(); - services.AddScoped(); - services.ConfigureOptions(); + services.AddMemoryCache(); + services.AddHttpClient(); + services.AddScoped(); + services.ConfigureOptions(); - services - .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(); + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(); - services.AddAuthorization(); + services.AddAuthorization(); - return services; + return services; + } } } diff --git a/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs b/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs index b6d15fb..f0f949a 100644 --- a/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs +++ b/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs @@ -1,35 +1,36 @@ using System.Reflection; using System.Security.Claims; -namespace Musify.Api.Authentication; - -public sealed class CurrentUser : IBindableFromHttpContext +namespace Musify.Api.Authentication { - private CurrentUser(ClaimsPrincipal principal) + public sealed class CurrentUser : IBindableFromHttpContext { - IsAuthenticated = principal.Identity?.IsAuthenticated ?? false; + private CurrentUser(ClaimsPrincipal principal) + { + IsAuthenticated = principal.Identity?.IsAuthenticated ?? false; - var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier); - if (long.TryParse(rawId, out var id) && id != 0) - Id = id; + var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier); + if (long.TryParse(rawId, out var id) && id != 0) + Id = id; - Username = principal.FindFirstValue(ClaimTypes.Name); - FirstName = principal.FindFirstValue(ClaimTypes.GivenName); - LastName = principal.FindFirstValue(ClaimTypes.Surname); + Username = principal.FindFirstValue(ClaimTypes.Name); + FirstName = principal.FindFirstValue(ClaimTypes.GivenName); + LastName = principal.FindFirstValue(ClaimTypes.Surname); + } + + public bool IsAuthenticated { get; } + + public long? Id { get; } + + public string? Username { get; } + + public string? FirstName { get; } + + public string? LastName { get; } + + public long RequiredId => Id ?? throw new InvalidOperationException("User Id claim is missing or invalid."); + + public static ValueTask BindAsync(HttpContext context, ParameterInfo parameter) => + ValueTask.FromResult(new CurrentUser(context.User)); } - - public bool IsAuthenticated { get; } - - public long? Id { get; } - - public string? Username { get; } - - public string? FirstName { get; } - - public string? LastName { get; } - - public long RequiredId => Id ?? throw new InvalidOperationException("User Id claim is missing or invalid."); - - public static ValueTask BindAsync(HttpContext context, ParameterInfo parameter) => - ValueTask.FromResult(new CurrentUser(context.User)); } diff --git a/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs b/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs index 2a705c9..c4d003b 100644 --- a/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs +++ b/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs @@ -7,135 +7,136 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Musify.Application.Users; -namespace Musify.Api.Authentication; - -public sealed class JwtBearerEventsHandler( - IMediator mediator, - IMemoryCache cache, - IHttpClientFactory httpClientFactory, - AuthenticationConfiguration authenticationConfiguration, - ILogger logger) : JwtBearerEvents +namespace Musify.Api.Authentication { - private static readonly TimeSpan UserSyncCacheTtl = TimeSpan.FromMinutes(15); - - private sealed record UserInfoResponse( - [property: JsonPropertyName("name")] string? Name, - [property: JsonPropertyName("preferred_username")] string? PreferredUsername, - [property: JsonPropertyName("email")] string? Email, - [property: JsonPropertyName("given_name")] string? GivenName, - [property: JsonPropertyName("family_name")] string? FamilyName, - [property: JsonPropertyName("picture")] string? Picture); - - public override async Task TokenValidated(TokenValidatedContext context) + public sealed class JwtBearerEventsHandler( + IMediator mediator, + IMemoryCache cache, + IHttpClientFactory httpClientFactory, + AuthenticationConfiguration authenticationConfiguration, + ILogger logger) : JwtBearerEvents { - var principal = context.Principal!; + private static readonly TimeSpan UserSyncCacheTtl = TimeSpan.FromMinutes(15); - var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier); - if (!long.TryParse(rawId, out var userId)) + private sealed record UserInfoResponse( + [property: JsonPropertyName("name")] string? Name, + [property: JsonPropertyName("preferred_username")] string? PreferredUsername, + [property: JsonPropertyName("email")] string? Email, + [property: JsonPropertyName("given_name")] string? GivenName, + [property: JsonPropertyName("family_name")] string? FamilyName, + [property: JsonPropertyName("picture")] string? Picture); + + public override async Task TokenValidated(TokenValidatedContext context) { - logger.LogWarning("Token subject '{Subject}' is not a numeric id; skipping user provisioning", rawId); - return; - } + var principal = context.Principal!; - var cacheKey = $"user-synced:{userId}"; - if (cache.TryGetValue(cacheKey, out _)) - return; - - var userInfo = await FetchUserInfoAsync(context, context.HttpContext.RequestAborted); - - var username = userInfo?.Name - ?? userInfo?.PreferredUsername - ?? principal.FindFirstValue("name") - ?? principal.FindFirstValue(ClaimTypes.Name) - ?? principal.FindFirstValue("preferred_username") - ?? userInfo?.Email - ?? principal.FindFirstValue("email") - ?? principal.FindFirstValue(ClaimTypes.Email) - ?? rawId!; - - var firstName = userInfo?.GivenName - ?? principal.FindFirstValue("given_name") - ?? principal.FindFirstValue(ClaimTypes.GivenName); - var lastName = userInfo?.FamilyName - ?? principal.FindFirstValue("family_name") - ?? principal.FindFirstValue(ClaimTypes.Surname); - var profilePictureUrl = userInfo?.Picture ?? principal.FindFirstValue("picture"); - - try - { - await mediator.Send( - new SyncUserCommand(userId, username, firstName, lastName, profilePictureUrl), - context.HttpContext.RequestAborted); - - cache.Set(cacheKey, true, UserSyncCacheTtl); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to sync user {UserId} during token validation", userId); - } - } - - private async Task FetchUserInfoAsync( - TokenValidatedContext context, CancellationToken cancellationToken) - { - var authorizationHeader = context.HttpContext.Request.Headers.Authorization.ToString(); - if (!authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - return null; - - var accessToken = authorizationHeader["Bearer ".Length..].Trim(); - if (accessToken.Length == 0) - return null; - - try - { - var userInfoEndpoint = await ResolveUserInfoEndpointAsync(context, cancellationToken); - - var client = httpClientFactory.CreateClient(); - using var request = new HttpRequestMessage(HttpMethod.Get, userInfoEndpoint) + var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier); + if (!long.TryParse(rawId, out var userId)) { - Headers = { Authorization = new AuthenticationHeaderValue("Bearer", accessToken) } - }; - - using var response = await client.SendAsync(request, cancellationToken); - if (!response.IsSuccessStatusCode) - { - logger.LogDebug("Userinfo request returned {StatusCode}", response.StatusCode); - return null; + logger.LogWarning("Token subject '{Subject}' is not a numeric id; skipping user provisioning", rawId); + return; } - return await response.Content.ReadFromJsonAsync(cancellationToken); + var cacheKey = $"user-synced:{userId}"; + if (cache.TryGetValue(cacheKey, out _)) + return; + + var userInfo = await FetchUserInfoAsync(context, context.HttpContext.RequestAborted); + + var username = userInfo?.Name + ?? userInfo?.PreferredUsername + ?? principal.FindFirstValue("name") + ?? principal.FindFirstValue(ClaimTypes.Name) + ?? principal.FindFirstValue("preferred_username") + ?? userInfo?.Email + ?? principal.FindFirstValue("email") + ?? principal.FindFirstValue(ClaimTypes.Email) + ?? rawId!; + + var firstName = userInfo?.GivenName + ?? principal.FindFirstValue("given_name") + ?? principal.FindFirstValue(ClaimTypes.GivenName); + var lastName = userInfo?.FamilyName + ?? principal.FindFirstValue("family_name") + ?? principal.FindFirstValue(ClaimTypes.Surname); + var profilePictureUrl = userInfo?.Picture ?? principal.FindFirstValue("picture"); + + try + { + await mediator.Send( + new SyncUserCommand(userId, username, firstName, lastName, profilePictureUrl), + context.HttpContext.RequestAborted); + + cache.Set(cacheKey, true, UserSyncCacheTtl); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to sync user {UserId} during token validation", userId); + } } - catch (Exception exception) + + private async Task FetchUserInfoAsync( + TokenValidatedContext context, CancellationToken cancellationToken) { - logger.LogWarning(exception, "Failed to fetch userinfo from identity provider"); - return null; - } - } + var authorizationHeader = context.HttpContext.Request.Headers.Authorization.ToString(); + if (!authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return null; - private async Task ResolveUserInfoEndpointAsync( - TokenValidatedContext context, CancellationToken cancellationToken) - { - var configurationManager = context.Options.ConfigurationManager; - if (configurationManager != null) + var accessToken = authorizationHeader["Bearer ".Length..].Trim(); + if (accessToken.Length == 0) + return null; + + try + { + var userInfoEndpoint = await ResolveUserInfoEndpointAsync(context, cancellationToken); + + var client = httpClientFactory.CreateClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, userInfoEndpoint) + { + Headers = { Authorization = new AuthenticationHeaderValue("Bearer", accessToken) } + }; + + using var response = await client.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + logger.LogDebug("Userinfo request returned {StatusCode}", response.StatusCode); + return null; + } + + return await response.Content.ReadFromJsonAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Failed to fetch userinfo from identity provider"); + return null; + } + } + + private async Task ResolveUserInfoEndpointAsync( + TokenValidatedContext context, CancellationToken cancellationToken) { - var configuration = await configurationManager.GetConfigurationAsync(cancellationToken); - if (configuration is OpenIdConnectConfiguration { UserInfoEndpoint: { Length: > 0 } endpoint }) - return endpoint; + var configurationManager = context.Options.ConfigurationManager; + if (configurationManager != null) + { + var configuration = await configurationManager.GetConfigurationAsync(cancellationToken); + if (configuration is OpenIdConnectConfiguration { UserInfoEndpoint: { Length: > 0 } endpoint }) + return endpoint; + } + + return $"{authenticationConfiguration.IssuerAddress.TrimEnd('/')}/oidc/v1/userinfo"; } - return $"{authenticationConfiguration.IssuerAddress.TrimEnd('/')}/oidc/v1/userinfo"; - } + public override Task AuthenticationFailed(AuthenticationFailedContext context) + { + logger.LogWarning(context.Exception, "JWT authentication failed"); + return Task.CompletedTask; + } - public override Task AuthenticationFailed(AuthenticationFailedContext context) - { - logger.LogWarning(context.Exception, "JWT authentication failed"); - return Task.CompletedTask; - } - - public override Task Challenge(JwtBearerChallengeContext context) - { - if (context.AuthenticateFailure != null) - logger.LogWarning("JWT challenge: {Error} - {Description}", context.Error, context.ErrorDescription); - return Task.CompletedTask; + public override Task Challenge(JwtBearerChallengeContext context) + { + if (context.AuthenticateFailure != null) + logger.LogWarning("JWT challenge: {Error} - {Description}", context.Error, context.ErrorDescription); + return Task.CompletedTask; + } } } diff --git a/backend/Hosts/Musify.Api/Authentication/JwtBearerOptionsSetup.cs b/backend/Hosts/Musify.Api/Authentication/JwtBearerOptionsSetup.cs index 880fd13..c46dc02 100644 --- a/backend/Hosts/Musify.Api/Authentication/JwtBearerOptionsSetup.cs +++ b/backend/Hosts/Musify.Api/Authentication/JwtBearerOptionsSetup.cs @@ -3,33 +3,34 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; -namespace Musify.Api.Authentication; - -public sealed class JwtBearerOptionsSetup(IOptions options) - : IConfigureNamedOptions +namespace Musify.Api.Authentication { - private readonly AuthenticationConfiguration configuration = options.Value; - - public void Configure(string? name, JwtBearerOptions options) + public sealed class JwtBearerOptionsSetup(IOptions options) + : IConfigureNamedOptions { - if (name != JwtBearerDefaults.AuthenticationScheme) - return; + private readonly AuthenticationConfiguration configuration = options.Value; - Configure(options); - } - - public void Configure(JwtBearerOptions options) - { - options.MetadataAddress = configuration.MetadataAddress; - options.RequireHttpsMetadata = configuration.RequireHttpsMetadata; - options.Audience = configuration.AudienceAddress; - options.TokenValidationParameters = new TokenValidationParameters + public void Configure(string? name, JwtBearerOptions options) { - ValidIssuer = configuration.IssuerAddress, - ValidAudience = configuration.AudienceAddress, - RoleClaimType = ClaimTypes.Role, - NameClaimType = ClaimTypes.Name, - }; - options.EventsType = typeof(JwtBearerEventsHandler); + if (name != JwtBearerDefaults.AuthenticationScheme) + return; + + Configure(options); + } + + public void Configure(JwtBearerOptions options) + { + options.MetadataAddress = configuration.MetadataAddress; + options.RequireHttpsMetadata = configuration.RequireHttpsMetadata; + options.Audience = configuration.AudienceAddress; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidIssuer = configuration.IssuerAddress, + ValidAudience = configuration.AudienceAddress, + RoleClaimType = ClaimTypes.Role, + NameClaimType = ClaimTypes.Name, + }; + options.EventsType = typeof(JwtBearerEventsHandler); + } } } diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/AddTracksRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/AddTracksRequest.cs index 6c0d0e5..3c25332 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/AddTracksRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/AddTracksRequest.cs @@ -1,3 +1,4 @@ -namespace Musify.Api.DataTransferObjects; - -public record AddTracksRequest(IReadOnlyList TrackIds); +namespace Musify.Api.DataTransferObjects +{ + public record AddTracksRequest(IReadOnlyList TrackIds); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/CreateAlbumRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/CreateAlbumRequest.cs index fa3292e..e8e8852 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/CreateAlbumRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/CreateAlbumRequest.cs @@ -1,7 +1,8 @@ -namespace Musify.Api.DataTransferObjects.Albums; - -public record CreateAlbumRequest( - string Title, - string? Description, - int? ReleaseYear, - Guid PictureIntentId); +namespace Musify.Api.DataTransferObjects.Albums +{ + public record CreateAlbumRequest( + string Title, + string? Description, + int? ReleaseYear, + Guid PictureIntentId); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs index a814831..1d6d03d 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Albums/UpdateAlbumRequest.cs @@ -1,7 +1,8 @@ -namespace Musify.Api.DataTransferObjects.Albums; - -public record UpdateAlbumRequest( - string Title, - string? Description, - int? ReleaseYear, - Guid? PictureIntentId); +namespace Musify.Api.DataTransferObjects.Albums +{ + public record UpdateAlbumRequest( + string Title, + string? Description, + int? ReleaseYear, + Guid? PictureIntentId); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs index 2ce34e2..d397a17 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Likes/ToggleLikeRequest.cs @@ -1,3 +1,4 @@ -namespace Musify.Api.DataTransferObjects.Likes; - -public record ToggleLikeRequest(Guid TrackId); +namespace Musify.Api.DataTransferObjects.Likes +{ + public record ToggleLikeRequest(Guid TrackId); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PictureUploadRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PictureUploadRequest.cs index c41becc..54ea555 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/PictureUploadRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/PictureUploadRequest.cs @@ -1,6 +1,7 @@ -namespace Musify.Api.DataTransferObjects; - -public record PictureUploadRequest( - string FileType, - string ContentType, - long? ExpectedSizeBytes = null); +namespace Musify.Api.DataTransferObjects +{ + public record PictureUploadRequest( + string FileType, + string ContentType, + long? ExpectedSizeBytes = null); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs index 5da333d..8cafadb 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/CreatePlayListRequest.cs @@ -1,9 +1,10 @@ using Musify.Domain.ValueObjects; -namespace Musify.Api.DataTransferObjects.PlayLists; - -public record CreatePlayListRequest( - string Name, - string? Description, - Guid? PictureIntentId, - PlayListVisibility Visibility = PlayListVisibility.Private); +namespace Musify.Api.DataTransferObjects.PlayLists +{ + public record CreatePlayListRequest( + string Name, + string? Description, + Guid? PictureIntentId, + PlayListVisibility Visibility = PlayListVisibility.Private); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs index 78a4d11..a49124c 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/PlayLists/UpdatePlayListRequest.cs @@ -1,9 +1,10 @@ using Musify.Domain.ValueObjects; -namespace Musify.Api.DataTransferObjects.PlayLists; - -public record UpdatePlayListRequest( - string? Name, - string? Description, - Guid? PictureIntentId, - PlayListVisibility? Visibility = null); +namespace Musify.Api.DataTransferObjects.PlayLists +{ + public record UpdatePlayListRequest( + string? Name, + string? Description, + Guid? PictureIntentId, + PlayListVisibility? Visibility = null); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs index 5e95895..2180bec 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/CreateTrackRequest.cs @@ -1,10 +1,11 @@ using Musify.Domain.ValueObjects; -namespace Musify.Api.DataTransferObjects.Tracks; - -public record CreateTrackRequest( - string Title, - Guid PictureIntentId, - Guid AudioIntentId, - IReadOnlyCollection Tags, - bool IsExplicit = false); +namespace Musify.Api.DataTransferObjects.Tracks +{ + public record CreateTrackRequest( + string Title, + Guid PictureIntentId, + Guid AudioIntentId, + IReadOnlyCollection Tags, + bool IsExplicit = false); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RecordListeningProgressRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RecordListeningProgressRequest.cs index 32eff3c..951a990 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RecordListeningProgressRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RecordListeningProgressRequest.cs @@ -1,3 +1,4 @@ -namespace Musify.Api.DataTransferObjects.Tracks; - -public record RecordListeningProgressRequest(double PlayedSeconds); +namespace Musify.Api.DataTransferObjects.Tracks +{ + public record RecordListeningProgressRequest(double PlayedSeconds); +} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RequestTrackUploadUrlsRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RequestTrackUploadUrlsRequest.cs index bd65125..dc7b887 100644 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RequestTrackUploadUrlsRequest.cs +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RequestTrackUploadUrlsRequest.cs @@ -1,9 +1,10 @@ -namespace Musify.Api.DataTransferObjects.Tracks; - -public record RequestTrackUploadUrlsRequest( - string PictureFileType, - string PictureContentType, - string AudioFileType, - string AudioContentType, - long? ExpectedPictureSizeBytes = null, - long? ExpectedAudioSizeBytes = null); +namespace Musify.Api.DataTransferObjects.Tracks +{ + public record RequestTrackUploadUrlsRequest( + string PictureFileType, + string PictureContentType, + string AudioFileType, + string AudioContentType, + long? ExpectedPictureSizeBytes = null, + long? ExpectedAudioSizeBytes = null); +} diff --git a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs index 2d7a4df..9fdd920 100644 --- a/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/AlbumEndpoints.cs @@ -13,263 +13,264 @@ using Musify.Application.Pictures.Responses; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -namespace Musify.Api.Endpoints; - -public static class AlbumEndpoints +namespace Musify.Api.Endpoints { - private const int MaxRecentAlbumsLimit = 50; - - public static IEndpointRouteBuilder MapAlbumEndpoints(this IEndpointRouteBuilder app) + public static class AlbumEndpoints { - var group = app.MapGroup("/albums") - .WithTags("Albums"); + private const int MaxRecentAlbumsLimit = 50; - group.MapGet("/", GetAlbums) - .AddEndpointFilter>() - .WithName("GetAlbums") - .WithSummary("Get paginated albums, optionally filtered by title") - .Produces>(); - - group.MapGet("/{id:guid}", GetAlbumById) - .WithName("GetAlbumById") - .WithSummary("Get an album by id") - .Produces() - .Produces(StatusCodes.Status404NotFound); - - group.MapGet("/users/{userId:long}", GetAlbumsByUserId) - .AddEndpointFilter>() - .WithName("GetAlbumsByUserId") - .WithSummary("Get paginated albums for a user") - .Produces>() - .Produces(StatusCodes.Status404NotFound); - - group.MapGet("/recent", GetRecentlyListenedAlbums) - .WithName("GetRecentlyListenedAlbums") - .WithSummary("Get the albums the current user listened to most recently") - .RequireAuthorization() - .Produces>() - .Produces(StatusCodes.Status401Unauthorized); - - group.MapGet("/{id:guid}/tracks", GetAlbumTracks) - .AddEndpointFilter>() - .WithName("GetAlbumTracks") - .WithSummary("Get the tracks of an album ordered by track number") - .Produces>() - .Produces(StatusCodes.Status404NotFound); - - group.MapGet("/{id:guid}/cover", GetAlbumCover) - .WithName("GetAlbumCover") - .WithSummary("Get an album cover image") - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status404NotFound); - - group.MapPost("/upload-picture", RequestAlbumPictureUpload) - .WithName("RequestAlbumPictureUpload") - .WithSummary("Request a pre-signed URL to upload an album picture") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces() - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); - - group.MapPost("/", CreateAlbum) - .WithName("CreateAlbum") - .WithSummary("Create a new album") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces(StatusCodes.Status201Created) - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); - - group.MapPut("/{id:guid}", UpdateAlbum) - .WithName("UpdateAlbum") - .WithSummary("Update an album") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces() - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status404NotFound); - - group.MapDelete("/{id:guid}", DeleteAlbum) - .WithName("DeleteAlbum") - .WithSummary("Delete an album") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status404NotFound); - - group.MapPost("/{id:guid}/tracks", AddAlbumTracks) - .WithName("AddAlbumTracks") - .WithSummary("Add your uploaded tracks to an album, skipping the ones it already has") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status404NotFound) - .Produces(StatusCodes.Status409Conflict); - - group.MapDelete("/{id:guid}/tracks/{trackId:guid}", RemoveTrackFromAlbum) - .WithName("RemoveTrackFromAlbum") - .WithSummary("Remove a track from an album") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status404NotFound); - - return app; - } - - private static async Task GetAlbums( - IMediator mediator, - string? title, - [AsParameters] PageQuery page, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAlbumsQuery(title, page.PageNumber, page.PageSize), cancellationToken); - return Results.Ok(result); - } - - private static async Task GetAlbumById( - IMediator mediator, - Guid id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAlbumByIdQuery(id), cancellationToken); - return result.ToHttpResult(); - } - - private static async Task GetAlbumsByUserId( - IMediator mediator, - long userId, - [AsParameters] PageQuery page, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAlbumsByUserIdQuery(userId, page.PageNumber, page.PageSize), cancellationToken); - return result.ToHttpResult(); - } - - private static async Task GetRecentlyListenedAlbums( - IMediator mediator, - CurrentUser currentUser, - CancellationToken cancellationToken, - int limit = 12) - { - if (limit is < 1 or > MaxRecentAlbumsLimit) + public static IEndpointRouteBuilder MapAlbumEndpoints(this IEndpointRouteBuilder app) { - return Results.ValidationProblem(new Dictionary - { - [nameof(limit)] = [$"'{nameof(limit)}' must be between 1 and {MaxRecentAlbumsLimit}."] - }); + var group = app.MapGroup("/albums") + .WithTags("Albums"); + + group.MapGet("/", GetAlbums) + .AddEndpointFilter>() + .WithName("GetAlbums") + .WithSummary("Get paginated albums, optionally filtered by title") + .Produces>(); + + group.MapGet("/{id:guid}", GetAlbumById) + .WithName("GetAlbumById") + .WithSummary("Get an album by id") + .Produces() + .Produces(StatusCodes.Status404NotFound); + + group.MapGet("/users/{userId:long}", GetAlbumsByUserId) + .AddEndpointFilter>() + .WithName("GetAlbumsByUserId") + .WithSummary("Get paginated albums for a user") + .Produces>() + .Produces(StatusCodes.Status404NotFound); + + group.MapGet("/recent", GetRecentlyListenedAlbums) + .WithName("GetRecentlyListenedAlbums") + .WithSummary("Get the albums the current user listened to most recently") + .RequireAuthorization() + .Produces>() + .Produces(StatusCodes.Status401Unauthorized); + + group.MapGet("/{id:guid}/tracks", GetAlbumTracks) + .AddEndpointFilter>() + .WithName("GetAlbumTracks") + .WithSummary("Get the tracks of an album ordered by track number") + .Produces>() + .Produces(StatusCodes.Status404NotFound); + + group.MapGet("/{id:guid}/cover", GetAlbumCover) + .WithName("GetAlbumCover") + .WithSummary("Get an album cover image") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); + + group.MapPost("/upload-picture", RequestAlbumPictureUpload) + .WithName("RequestAlbumPictureUpload") + .WithSummary("Request a pre-signed URL to upload an album picture") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); + + group.MapPost("/", CreateAlbum) + .WithName("CreateAlbum") + .WithSummary("Create a new album") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces(StatusCodes.Status201Created) + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); + + group.MapPut("/{id:guid}", UpdateAlbum) + .WithName("UpdateAlbum") + .WithSummary("Update an album") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); + + group.MapDelete("/{id:guid}", DeleteAlbum) + .WithName("DeleteAlbum") + .WithSummary("Delete an album") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); + + group.MapPost("/{id:guid}/tracks", AddAlbumTracks) + .WithName("AddAlbumTracks") + .WithSummary("Add your uploaded tracks to an album, skipping the ones it already has") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); + + group.MapDelete("/{id:guid}/tracks/{trackId:guid}", RemoveTrackFromAlbum) + .WithName("RemoveTrackFromAlbum") + .WithSummary("Remove a track from an album") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); + + return app; } - var result = await mediator.Send(new GetRecentlyListenedAlbumsQuery(currentUser.RequiredId, limit), cancellationToken); - return Results.Ok(result); - } + private static async Task GetAlbums( + IMediator mediator, + string? title, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetAlbumsQuery(title, page.PageNumber, page.PageSize), cancellationToken); + return Results.Ok(result); + } - private static async Task GetAlbumTracks( - IMediator mediator, - Guid id, - [AsParameters] PageQuery page, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAlbumTracksQuery(id, page.PageNumber, page.PageSize), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetAlbumById( + IMediator mediator, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetAlbumByIdQuery(id), cancellationToken); + return result.ToHttpResult(); + } - private static Task GetAlbumCover( - IMediator mediator, - IStorageService storageService, - HttpResponse response, - Guid id, - CancellationToken cancellationToken, - string size = "medium") - { - return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.Album, id, size, cancellationToken); - } + private static async Task GetAlbumsByUserId( + IMediator mediator, + long userId, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetAlbumsByUserIdQuery(userId, page.PageNumber, page.PageSize), cancellationToken); + return result.ToHttpResult(); + } - private static async Task RequestAlbumPictureUpload( - IMediator mediator, - CurrentUser currentUser, - PictureUploadRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new RequestAlbumPictureUploadCommand(currentUser.RequiredId, request.FileType, request.ContentType, request.ExpectedSizeBytes), - cancellationToken); + private static async Task GetRecentlyListenedAlbums( + IMediator mediator, + CurrentUser currentUser, + CancellationToken cancellationToken, + int limit = 12) + { + if (limit is < 1 or > MaxRecentAlbumsLimit) + { + return Results.ValidationProblem(new Dictionary + { + [nameof(limit)] = [$"'{nameof(limit)}' must be between 1 and {MaxRecentAlbumsLimit}."] + }); + } - return result.ToHttpResult(); - } + var result = await mediator.Send(new GetRecentlyListenedAlbumsQuery(currentUser.RequiredId, limit), cancellationToken); + return Results.Ok(result); + } - private static async Task CreateAlbum( - IMediator mediator, - CurrentUser currentUser, - CreateAlbumRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new CreateAlbumCommand(currentUser.RequiredId, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId), - cancellationToken); + private static async Task GetAlbumTracks( + IMediator mediator, + Guid id, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetAlbumTracksQuery(id, page.PageNumber, page.PageSize), cancellationToken); + return result.ToHttpResult(); + } - return result.ToCreatedResult(album => $"/albums/{album.Id}"); - } + private static Task GetAlbumCover( + IMediator mediator, + IStorageService storageService, + HttpResponse response, + Guid id, + CancellationToken cancellationToken, + string size = "medium") + { + return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.Album, id, size, cancellationToken); + } - private static async Task UpdateAlbum( - IMediator mediator, - CurrentUser currentUser, - Guid id, - UpdateAlbumRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new UpdateAlbumCommand(currentUser.RequiredId, id, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId), - cancellationToken); + private static async Task RequestAlbumPictureUpload( + IMediator mediator, + CurrentUser currentUser, + PictureUploadRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new RequestAlbumPictureUploadCommand(currentUser.RequiredId, request.FileType, request.ContentType, request.ExpectedSizeBytes), + cancellationToken); - return result.ToHttpResult(); - } + return result.ToHttpResult(); + } - private static async Task DeleteAlbum( - IMediator mediator, - CurrentUser currentUser, - Guid id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new DeleteAlbumCommand(currentUser.RequiredId, id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task CreateAlbum( + IMediator mediator, + CurrentUser currentUser, + CreateAlbumRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new CreateAlbumCommand(currentUser.RequiredId, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId), + cancellationToken); - private static async Task AddAlbumTracks( - IMediator mediator, - CurrentUser currentUser, - Guid id, - AddTracksRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new AddTracksToAlbumCommand(currentUser.RequiredId, id, request.TrackIds), - cancellationToken); + return result.ToCreatedResult(album => $"/albums/{album.Id}"); + } - return result.ToHttpResult(); - } + private static async Task UpdateAlbum( + IMediator mediator, + CurrentUser currentUser, + Guid id, + UpdateAlbumRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new UpdateAlbumCommand(currentUser.RequiredId, id, request.Title, request.Description, request.ReleaseYear, request.PictureIntentId), + cancellationToken); - private static async Task RemoveTrackFromAlbum( - IMediator mediator, - CurrentUser currentUser, - Guid id, - Guid trackId, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new RemoveTrackFromAlbumCommand(currentUser.RequiredId, id, trackId), - cancellationToken); + return result.ToHttpResult(); + } - return result.ToHttpResult(); + private static async Task DeleteAlbum( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new DeleteAlbumCommand(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task AddAlbumTracks( + IMediator mediator, + CurrentUser currentUser, + Guid id, + AddTracksRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new AddTracksToAlbumCommand(currentUser.RequiredId, id, request.TrackIds), + cancellationToken); + + return result.ToHttpResult(); + } + + private static async Task RemoveTrackFromAlbum( + IMediator mediator, + CurrentUser currentUser, + Guid id, + Guid trackId, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new RemoveTrackFromAlbumCommand(currentUser.RequiredId, id, trackId), + cancellationToken); + + return result.ToHttpResult(); + } } } diff --git a/backend/Hosts/Musify.Api/Endpoints/ConfigEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/ConfigEndpoints.cs index 517e79c..78f8474 100644 --- a/backend/Hosts/Musify.Api/Endpoints/ConfigEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/ConfigEndpoints.cs @@ -2,28 +2,29 @@ using Mediator; using Musify.Application.Configuration; using Musify.Application.Configuration.Responses; -namespace Musify.Api.Endpoints; - -public static class ConfigEndpoints +namespace Musify.Api.Endpoints { - public static IEndpointRouteBuilder MapConfigEndpoints(this IEndpointRouteBuilder app) + public static class ConfigEndpoints { - var group = app.MapGroup("/config") - .WithTags("Config"); + public static IEndpointRouteBuilder MapConfigEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/config") + .WithTags("Config"); - group.MapGet("/playback", GetPlaybackPublicConfig) - .WithName("GetPlaybackPublicConfig") - .WithSummary("Get the public playback configuration (whether anonymous listening is allowed). Works anonymously") - .Produces(); + group.MapGet("/playback", GetPlaybackPublicConfig) + .WithName("GetPlaybackPublicConfig") + .WithSummary("Get the public playback configuration (whether anonymous listening is allowed). Works anonymously") + .Produces(); - return app; - } + return app; + } - private static async Task GetPlaybackPublicConfig( - IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetPlaybackPublicConfigQuery(), cancellationToken); - return Results.Ok(result); + private static async Task GetPlaybackPublicConfig( + IMediator mediator, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetPlaybackPublicConfigQuery(), cancellationToken); + return Results.Ok(result); + } } } diff --git a/backend/Hosts/Musify.Api/Endpoints/CoverEndpoint.cs b/backend/Hosts/Musify.Api/Endpoints/CoverEndpoint.cs index 7bf3570..474a699 100644 --- a/backend/Hosts/Musify.Api/Endpoints/CoverEndpoint.cs +++ b/backend/Hosts/Musify.Api/Endpoints/CoverEndpoint.cs @@ -3,29 +3,30 @@ using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Pictures; -namespace Musify.Api.Endpoints; - -internal static class CoverEndpoint +namespace Musify.Api.Endpoints { - public static async Task Stream( - IMediator mediator, - IStorageService storageService, - HttpResponse response, - CoverOwner owner, - Guid id, - string size, - CancellationToken cancellationToken) + internal static class CoverEndpoint { - var result = await mediator.Send(new GetCoverQuery(owner, id, PictureSizeParser.Parse(size)), cancellationToken); - if (result.IsError) - return Results.NotFound(); + public static async Task Stream( + IMediator mediator, + IStorageService storageService, + HttpResponse response, + CoverOwner owner, + Guid id, + string size, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetCoverQuery(owner, id, PictureSizeParser.Parse(size)), cancellationToken); + if (result.IsError) + return Results.NotFound(); - var location = result.Value; - var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken); - if (stream == null) - return Results.NotFound(); + var location = result.Value; + var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken); + if (stream == null) + return Results.NotFound(); - response.Headers.CacheControl = "public, max-age=31536000, immutable"; - return Results.Stream(stream, location.ContentType); + response.Headers.CacheControl = "public, max-age=31536000, immutable"; + return Results.Stream(stream, location.ContentType); + } } } diff --git a/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs index d779c88..3b99a5f 100644 --- a/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs @@ -2,41 +2,42 @@ using Mediator; using Musify.Application.Genres; using Musify.Application.Genres.Responses; -namespace Musify.Api.Endpoints; - -public static class GenreEndpoints +namespace Musify.Api.Endpoints { - public static IEndpointRouteBuilder MapGenreEndpoints(this IEndpointRouteBuilder app) + public static class GenreEndpoints { - var group = app.MapGroup("/genres") - .WithTags("Genres"); + public static IEndpointRouteBuilder MapGenreEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/genres") + .WithTags("Genres"); - group.MapGet("/", GetGenres) - .WithName("GetGenres") - .WithSummary("Get the genres that have at least one track, most populated first. Works anonymously") - .Produces>(); + group.MapGet("/", GetGenres) + .WithName("GetGenres") + .WithSummary("Get the genres that have at least one track, most populated first. Works anonymously") + .Produces>(); - group.MapGet("/available", GetAvailableGenres) - .WithName("GetAvailableGenres") - .WithSummary("Get every genre a track can be tagged with, including the genres it cannot be combined with. Works anonymously") - .Produces>(); + group.MapGet("/available", GetAvailableGenres) + .WithName("GetAvailableGenres") + .WithSummary("Get every genre a track can be tagged with, including the genres it cannot be combined with. Works anonymously") + .Produces>(); - return app; - } + return app; + } - private static async Task GetAvailableGenres( - IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAvailableGenresQuery(), cancellationToken); - return Results.Ok(result); - } + private static async Task GetAvailableGenres( + IMediator mediator, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetAvailableGenresQuery(), cancellationToken); + return Results.Ok(result); + } - private static async Task GetGenres( - IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetGenresQuery(), cancellationToken); - return Results.Ok(result); + private static async Task GetGenres( + IMediator mediator, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetGenresQuery(), cancellationToken); + return Results.Ok(result); + } } } diff --git a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs index af30182..f2c1a9c 100644 --- a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs @@ -8,53 +8,54 @@ using Musify.Application.Likes; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -namespace Musify.Api.Endpoints; - -public static class LikeEndpoints +namespace Musify.Api.Endpoints { - public static IEndpointRouteBuilder MapLikeEndpoints(this IEndpointRouteBuilder app) + public static class LikeEndpoints { - var group = app.MapGroup("/likes") - .WithTags("Likes") - .RequireAuthorization(); + public static IEndpointRouteBuilder MapLikeEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/likes") + .WithTags("Likes") + .RequireAuthorization(); - group.MapGet("/", GetLikedTracks) - .AddEndpointFilter>() - .WithName("GetLikedTracks") - .WithSummary("Get the current user's liked tracks") - .Produces>() - .Produces(StatusCodes.Status401Unauthorized); + group.MapGet("/", GetLikedTracks) + .AddEndpointFilter>() + .WithName("GetLikedTracks") + .WithSummary("Get the current user's liked tracks") + .Produces>() + .Produces(StatusCodes.Status401Unauthorized); - group.MapPost("/toggle", ToggleTrackLike) - .WithName("ToggleTrackLike") - .WithSummary("Like or unlike a track by id") - .Produces() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + group.MapPost("/toggle", ToggleTrackLike) + .WithName("ToggleTrackLike") + .WithSummary("Like or unlike a track by id") + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); - return app; - } + return app; + } - private static async Task GetLikedTracks( - IMediator mediator, - CurrentUser currentUser, - [AsParameters] PageQuery page, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetLikedTracksQuery(currentUser.RequiredId, page.PageNumber, page.PageSize), cancellationToken); - return Results.Ok(result); - } + private static async Task GetLikedTracks( + IMediator mediator, + CurrentUser currentUser, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetLikedTracksQuery(currentUser.RequiredId, page.PageNumber, page.PageSize), cancellationToken); + return Results.Ok(result); + } - private static async Task ToggleTrackLike( - IMediator mediator, - CurrentUser currentUser, - ToggleLikeRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new ToggleTrackLikeCommand(currentUser.RequiredId, request.TrackId), - cancellationToken); + private static async Task ToggleTrackLike( + IMediator mediator, + CurrentUser currentUser, + ToggleLikeRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new ToggleTrackLikeCommand(currentUser.RequiredId, request.TrackId), + cancellationToken); - return result.ToHttpResult(); + return result.ToHttpResult(); + } } } diff --git a/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs index b0b0736..1924e2c 100644 --- a/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/MixEndpoints.cs @@ -4,48 +4,49 @@ using Musify.Api.Extensions; using Musify.Application.Mixes; using Musify.Application.Mixes.Responses; -namespace Musify.Api.Endpoints; - -public static class MixEndpoints +namespace Musify.Api.Endpoints { - public static IEndpointRouteBuilder MapMixEndpoints(this IEndpointRouteBuilder app) + public static class MixEndpoints { - var group = app.MapGroup("/mixes") - .WithTags("Mixes") - .RequireAuthorization(); + public static IEndpointRouteBuilder MapMixEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/mixes") + .WithTags("Mixes") + .RequireAuthorization(); - group.MapGet("/", GetMixes) - .WithName("GetMixes") - .WithSummary("Get the server-generated mixes of the current user") - .Produces>() - .Produces(StatusCodes.Status401Unauthorized); + group.MapGet("/", GetMixes) + .WithName("GetMixes") + .WithSummary("Get the server-generated mixes of the current user") + .Produces>() + .Produces(StatusCodes.Status401Unauthorized); - group.MapGet("/{id:guid}", GetMixById) - .WithName("GetMixById") - .WithSummary("Get a mix with all of its songs") - .Produces() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:guid}", GetMixById) + .WithName("GetMixById") + .WithSummary("Get a mix with all of its songs") + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); - return app; - } + return app; + } - private static async Task GetMixes( - IMediator mediator, - CurrentUser currentUser, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetMixesByUserIdQuery(currentUser.RequiredId), cancellationToken); - return Results.Ok(result); - } + private static async Task GetMixes( + IMediator mediator, + CurrentUser currentUser, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetMixesByUserIdQuery(currentUser.RequiredId), cancellationToken); + return Results.Ok(result); + } - private static async Task GetMixById( - IMediator mediator, - CurrentUser currentUser, - Guid id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetMixByIdQuery(currentUser.RequiredId, id), cancellationToken); - return result.ToHttpResult(); + private static async Task GetMixById( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetMixByIdQuery(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } } } diff --git a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs index d21d8b4..dd14e7f 100644 --- a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -13,223 +13,224 @@ using Musify.Application.PlayLists.Responses; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; -namespace Musify.Api.Endpoints; - -public static class PlayListEndpoints +namespace Musify.Api.Endpoints { - public static IEndpointRouteBuilder MapPlayListEndpoints(this IEndpointRouteBuilder app) + public static class PlayListEndpoints { - var group = app.MapGroup("/playlists") - .WithTags("PlayLists"); + public static IEndpointRouteBuilder MapPlayListEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/playlists") + .WithTags("PlayLists"); - group.MapGet("/{id:guid}", GetPlayListById) - .WithName("GetPlayListById") - .WithSummary("Get a playlist by id") - .Produces() - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:guid}", GetPlayListById) + .WithName("GetPlayListById") + .WithSummary("Get a playlist by id") + .Produces() + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/users/{userId:long}", GetPlayListsByUserId) - .AddEndpointFilter>() - .WithName("GetPlayListsByUserId") - .WithSummary("Get paginated playlists for a user. Private playlists are only returned to their owner") - .Produces>(); + group.MapGet("/users/{userId:long}", GetPlayListsByUserId) + .AddEndpointFilter>() + .WithName("GetPlayListsByUserId") + .WithSummary("Get paginated playlists for a user. Private playlists are only returned to their owner") + .Produces>(); - group.MapGet("/{id:guid}/tracks", GetPlayListTracks) - .AddEndpointFilter>() - .WithName("GetPlayListTracks") - .WithSummary("Get paginated tracks of a playlist") - .Produces>() - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:guid}/tracks", GetPlayListTracks) + .AddEndpointFilter>() + .WithName("GetPlayListTracks") + .WithSummary("Get paginated tracks of a playlist") + .Produces>() + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id:guid}/cover", GetPlayListCover) - .WithName("GetPlayListCover") - .WithSummary("Get a playlist cover image") - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:guid}/cover", GetPlayListCover) + .WithName("GetPlayListCover") + .WithSummary("Get a playlist cover image") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); - group.MapPost("/", CreatePlayList) - .WithName("CreatePlayList") - .WithSummary("Create a new playlist") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces(StatusCodes.Status201Created) - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + group.MapPost("/", CreatePlayList) + .WithName("CreatePlayList") + .WithSummary("Create a new playlist") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces(StatusCodes.Status201Created) + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); - group.MapPut("/{id:guid}", UpdatePlayList) - .WithName("UpdatePlayList") - .WithSummary("Update an existing playlist") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces() - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status404NotFound); + group.MapPut("/{id:guid}", UpdatePlayList) + .WithName("UpdatePlayList") + .WithSummary("Update an existing playlist") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); - group.MapDelete("/{id:guid}", DeletePlayList) - .WithName("DeletePlayList") - .WithSummary("Delete a playlist") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status404NotFound); + group.MapDelete("/{id:guid}", DeletePlayList) + .WithName("DeletePlayList") + .WithSummary("Delete a playlist") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); - group.MapPost("/upload-picture", RequestPlayListPictureUpload) - .WithName("RequestPlayListPictureUpload") - .WithSummary("Request a pre-signed URL to upload a playlist picture") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces() - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + group.MapPost("/upload-picture", RequestPlayListPictureUpload) + .WithName("RequestPlayListPictureUpload") + .WithSummary("Request a pre-signed URL to upload a playlist picture") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); - group.MapPost("/{id:guid}/tracks", AddPlayListTracks) - .WithName("AddPlayListTracks") - .WithSummary("Add tracks to a playlist, skipping the ones it already has") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status404NotFound) - .Produces(StatusCodes.Status409Conflict); + group.MapPost("/{id:guid}/tracks", AddPlayListTracks) + .WithName("AddPlayListTracks") + .WithSummary("Add tracks to a playlist, skipping the ones it already has") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); - group.MapDelete("/{id:guid}/tracks/{trackId:guid}", RemoveTrackFromPlayList) - .WithName("RemoveTrackFromPlayList") - .WithSummary("Remove a track from a playlist") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status404NotFound); + group.MapDelete("/{id:guid}/tracks/{trackId:guid}", RemoveTrackFromPlayList) + .WithName("RemoveTrackFromPlayList") + .WithSummary("Remove a track from a playlist") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); - return app; - } + return app; + } - private static async Task GetPlayListById( - IMediator mediator, - CurrentUser currentUser, - Guid id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetPlayListByIdQuery(id, currentUser.Id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetPlayListById( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetPlayListByIdQuery(id, currentUser.Id), cancellationToken); + return result.ToHttpResult(); + } - private static async Task GetPlayListsByUserId( - IMediator mediator, - CurrentUser currentUser, - long userId, - string? name, - [AsParameters] PageQuery page, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken); - return Results.Ok(result); - } + private static async Task GetPlayListsByUserId( + IMediator mediator, + CurrentUser currentUser, + long userId, + string? name, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken); + return Results.Ok(result); + } - private static async Task GetPlayListTracks( - IMediator mediator, - CurrentUser currentUser, - Guid id, - [AsParameters] PageQuery page, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetPlayListTracksQuery(id, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetPlayListTracks( + IMediator mediator, + CurrentUser currentUser, + Guid id, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetPlayListTracksQuery(id, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken); + return result.ToHttpResult(); + } - private static Task GetPlayListCover( - IMediator mediator, - IStorageService storageService, - HttpResponse response, - Guid id, - CancellationToken cancellationToken, - string size = "medium") - { - return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.PlayList, id, size, cancellationToken); - } + private static Task GetPlayListCover( + IMediator mediator, + IStorageService storageService, + HttpResponse response, + Guid id, + CancellationToken cancellationToken, + string size = "medium") + { + return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.PlayList, id, size, cancellationToken); + } - private static async Task CreatePlayList( - IMediator mediator, - CurrentUser currentUser, - CreatePlayListRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new CreatePlayListCommand(currentUser.RequiredId, request.Name, request.Description, request.PictureIntentId, request.Visibility), - cancellationToken); + private static async Task CreatePlayList( + IMediator mediator, + CurrentUser currentUser, + CreatePlayListRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new CreatePlayListCommand(currentUser.RequiredId, request.Name, request.Description, request.PictureIntentId, request.Visibility), + cancellationToken); - return result.ToCreatedResult(playList => $"/playlists/{playList.Id}"); - } + return result.ToCreatedResult(playList => $"/playlists/{playList.Id}"); + } - private static async Task UpdatePlayList( - IMediator mediator, - CurrentUser currentUser, - Guid id, - UpdatePlayListRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new UpdatePlayListCommand(currentUser.RequiredId, id, request.Name, request.Description, request.PictureIntentId, request.Visibility), - cancellationToken); + private static async Task UpdatePlayList( + IMediator mediator, + CurrentUser currentUser, + Guid id, + UpdatePlayListRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new UpdatePlayListCommand(currentUser.RequiredId, id, request.Name, request.Description, request.PictureIntentId, request.Visibility), + cancellationToken); - return result.ToHttpResult(); - } + return result.ToHttpResult(); + } - private static async Task DeletePlayList( - IMediator mediator, - CurrentUser currentUser, - Guid id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new DeletePlayListCommand(currentUser.RequiredId, id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task DeletePlayList( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new DeletePlayListCommand(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } - private static async Task RequestPlayListPictureUpload( - IMediator mediator, - CurrentUser currentUser, - PictureUploadRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new RequestPlayListPictureUploadCommand(currentUser.RequiredId, request.FileType, request.ContentType, request.ExpectedSizeBytes), - cancellationToken); + private static async Task RequestPlayListPictureUpload( + IMediator mediator, + CurrentUser currentUser, + PictureUploadRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new RequestPlayListPictureUploadCommand(currentUser.RequiredId, request.FileType, request.ContentType, request.ExpectedSizeBytes), + cancellationToken); - return result.ToHttpResult(); - } + return result.ToHttpResult(); + } - private static async Task AddPlayListTracks( - IMediator mediator, - CurrentUser currentUser, - Guid id, - AddTracksRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new AddTracksToPlayListCommand(currentUser.RequiredId, id, request.TrackIds), - cancellationToken); + private static async Task AddPlayListTracks( + IMediator mediator, + CurrentUser currentUser, + Guid id, + AddTracksRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new AddTracksToPlayListCommand(currentUser.RequiredId, id, request.TrackIds), + cancellationToken); - return result.ToHttpResult(); - } + return result.ToHttpResult(); + } - private static async Task RemoveTrackFromPlayList( - IMediator mediator, - CurrentUser currentUser, - Guid id, - Guid trackId, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new RemoveTrackFromPlayListCommand(currentUser.RequiredId, id, trackId), - cancellationToken); + private static async Task RemoveTrackFromPlayList( + IMediator mediator, + CurrentUser currentUser, + Guid id, + Guid trackId, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new RemoveTrackFromPlayListCommand(currentUser.RequiredId, id, trackId), + cancellationToken); - return result.ToHttpResult(); + return result.ToHttpResult(); + } } } diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index c8ee6e1..7a15b54 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -10,196 +10,197 @@ using Musify.Application.Shared; using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; -namespace Musify.Api.Endpoints; - -public static class TrackEndpoints +namespace Musify.Api.Endpoints { - public static IEndpointRouteBuilder MapTrackEndpoints(this IEndpointRouteBuilder app) + public static class TrackEndpoints { - var group = app.MapGroup("/tracks") - .WithTags("Tracks"); + public static IEndpointRouteBuilder MapTrackEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/tracks") + .WithTags("Tracks"); - group.MapGet("/", GetTracks) - .AddEndpointFilter>() - .WithName("GetTracks") - .WithSummary("Get paginated tracks, optionally filtered by name and genre") - .Produces>() - .ProducesValidationProblem(); + group.MapGet("/", GetTracks) + .AddEndpointFilter>() + .WithName("GetTracks") + .WithSummary("Get paginated tracks, optionally filtered by name and genre") + .Produces>() + .ProducesValidationProblem(); - group.MapGet("/{id:guid}", GetTrackById) - .WithName("GetTrackById") - .WithSummary("Get a track by id") - .Produces() - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:guid}", GetTrackById) + .WithName("GetTrackById") + .WithSummary("Get a track by id") + .Produces() + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id:guid}/cover", GetTrackCover) - .WithName("GetTrackCover") - .WithSummary("Get a track cover image") - .Produces(StatusCodes.Status200OK) - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:guid}/cover", GetTrackCover) + .WithName("GetTrackCover") + .WithSummary("Get a track cover image") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id:guid}/stream", GetTrackStream) - .WithName("GetTrackStream") - .WithSummary("Get a streaming manifest URL and ticket for a track. Works anonymously when the playback configuration allows it") - .Produces() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:guid}/stream", GetTrackStream) + .WithName("GetTrackStream") + .WithSummary("Get a streaming manifest URL and ticket for a track. Works anonymously when the playback configuration allows it") + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); - group.MapPut("/listens/{listenId:guid}/progress", RecordListeningProgress) - .AddEndpointFilter>() - .WithName("RecordListeningProgress") - .WithSummary("Report the total seconds actually played for a listen started by the stream endpoint") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + group.MapPut("/listens/{listenId:guid}/progress", RecordListeningProgress) + .AddEndpointFilter>() + .WithName("RecordListeningProgress") + .WithSummary("Report the total seconds actually played for a listen started by the stream endpoint") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/users/{userId:long}", GetTracksByUserId) - .AddEndpointFilter>() - .WithName("GetTracksByUserId") - .WithSummary("Get paginated tracks for a user") - .Produces>(); + group.MapGet("/users/{userId:long}", GetTracksByUserId) + .AddEndpointFilter>() + .WithName("GetTracksByUserId") + .WithSummary("Get paginated tracks for a user") + .Produces>(); - group.MapPost("/", CreateTrack) - .WithName("CreateTrack") - .WithSummary("Create a new track") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces(StatusCodes.Status201Created) - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound) - .Produces(StatusCodes.Status409Conflict); + group.MapPost("/", CreateTrack) + .WithName("CreateTrack") + .WithSummary("Create a new track") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces(StatusCodes.Status201Created) + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); - group.MapDelete("/{id:guid}", DeleteTrack) - .WithName("DeleteTrack") - .WithSummary("Delete a track") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status404NotFound) - .Produces(StatusCodes.Status409Conflict); + group.MapDelete("/{id:guid}", DeleteTrack) + .WithName("DeleteTrack") + .WithSummary("Delete a track") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); - group.MapPost("/upload-urls", RequestTrackUploadUrls) - .WithName("RequestTrackUploadUrls") - .WithSummary("Request pre-signed URLs to upload track picture and audio") - .AddEndpointFilter>() - .RequireAuthorization() - .Produces() - .ProducesValidationProblem() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound) - .Produces(StatusCodes.Status409Conflict); + group.MapPost("/upload-urls", RequestTrackUploadUrls) + .WithName("RequestTrackUploadUrls") + .WithSummary("Request pre-signed URLs to upload track picture and audio") + .AddEndpointFilter>() + .RequireAuthorization() + .Produces() + .ProducesValidationProblem() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); - return app; - } + return app; + } - private static async Task GetTracks( - IMediator mediator, - string? name, - GenreParameter? genre, - [AsParameters] PageQuery page, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetTracksQuery(name, page.PageNumber, page.PageSize, genre?.Value), cancellationToken); - return Results.Ok(result); - } + private static async Task GetTracks( + IMediator mediator, + string? name, + GenreParameter? genre, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetTracksQuery(name, page.PageNumber, page.PageSize, genre?.Value), cancellationToken); + return Results.Ok(result); + } - private static async Task GetTrackById( - IMediator mediator, - Guid id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetTrackByIdQuery(id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetTrackById( + IMediator mediator, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetTrackByIdQuery(id), cancellationToken); + return result.ToHttpResult(); + } - private static Task GetTrackCover( - IMediator mediator, - IStorageService storageService, - HttpResponse response, - Guid id, - CancellationToken cancellationToken, - string size = "medium") - { - return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.Track, id, size, cancellationToken); - } + private static Task GetTrackCover( + IMediator mediator, + IStorageService storageService, + HttpResponse response, + Guid id, + CancellationToken cancellationToken, + string size = "medium") + { + return CoverEndpoint.Stream(mediator, storageService, response, CoverOwner.Track, id, size, cancellationToken); + } - private static async Task GetTrackStream( - IMediator mediator, - CurrentUser currentUser, - Guid id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new StartListeningCommand(id, currentUser.Id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetTrackStream( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new StartListeningCommand(id, currentUser.Id), cancellationToken); + return result.ToHttpResult(); + } - private static async Task RecordListeningProgress( - IMediator mediator, - CurrentUser currentUser, - Guid listenId, - RecordListeningProgressRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new RecordListeningProgressCommand(currentUser.RequiredId, listenId, request.PlayedSeconds), - cancellationToken); - return result.ToHttpResult(); - } + private static async Task RecordListeningProgress( + IMediator mediator, + CurrentUser currentUser, + Guid listenId, + RecordListeningProgressRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new RecordListeningProgressCommand(currentUser.RequiredId, listenId, request.PlayedSeconds), + cancellationToken); + return result.ToHttpResult(); + } - private static async Task GetTracksByUserId( - IMediator mediator, - long userId, - string? name, - [AsParameters] PageQuery page, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetTracksByUserId( + IMediator mediator, + long userId, + string? name, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize), cancellationToken); + return result.ToHttpResult(); + } - private static async Task CreateTrack( - IMediator mediator, - CurrentUser currentUser, - CreateTrackRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags, request.IsExplicit), - cancellationToken); + private static async Task CreateTrack( + IMediator mediator, + CurrentUser currentUser, + CreateTrackRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags, request.IsExplicit), + cancellationToken); - return result.ToCreatedResult(track => $"/tracks/{track.Id}"); - } + return result.ToCreatedResult(track => $"/tracks/{track.Id}"); + } - private static async Task DeleteTrack( - IMediator mediator, - CurrentUser currentUser, - Guid id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new DeleteTrackCommand(currentUser.RequiredId, id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task DeleteTrack( + IMediator mediator, + CurrentUser currentUser, + Guid id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new DeleteTrackCommand(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } - private static async Task RequestTrackUploadUrls( - IMediator mediator, - CurrentUser currentUser, - RequestTrackUploadUrlsRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new RequestTrackUploadUrlsCommand( - currentUser.RequiredId, - request.PictureFileType, - request.PictureContentType, - request.AudioFileType, - request.AudioContentType, - request.ExpectedPictureSizeBytes, - request.ExpectedAudioSizeBytes), - cancellationToken); + private static async Task RequestTrackUploadUrls( + IMediator mediator, + CurrentUser currentUser, + RequestTrackUploadUrlsRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new RequestTrackUploadUrlsCommand( + currentUser.RequiredId, + request.PictureFileType, + request.PictureContentType, + request.AudioFileType, + request.AudioContentType, + request.ExpectedPictureSizeBytes, + request.ExpectedAudioSizeBytes), + cancellationToken); - return result.ToHttpResult(); + return result.ToHttpResult(); + } } } diff --git a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs index 1c6815e..bbd8e72 100644 --- a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs @@ -9,204 +9,205 @@ using Musify.Application.Tracks.Responses; using Musify.Application.Users; using Musify.Application.Users.Responses; -namespace Musify.Api.Endpoints; - -public static class UserEndpoints +namespace Musify.Api.Endpoints { - public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder app) + public static class UserEndpoints { - var group = app.MapGroup("/users") - .WithTags("Users"); + public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/users") + .WithTags("Users"); - group.MapGet("/", GetUsers) - .AddEndpointFilter>() - .WithName("GetUsers") - .WithSummary("Get paginated users") - .Produces>(); + group.MapGet("/", GetUsers) + .AddEndpointFilter>() + .WithName("GetUsers") + .WithSummary("Get paginated users") + .Produces>(); - group.MapGet("/{id:long}", GetUserById) - .WithName("GetUserById") - .WithSummary("Get a user by id") - .Produces() - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:long}", GetUserById) + .WithName("GetUserById") + .WithSummary("Get a user by id") + .Produces() + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id:long}/listening-history", GetListeningHistory) - .WithName("GetListeningHistory") - .WithSummary("Get a user's listening history") - .Produces>() - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:long}/listening-history", GetListeningHistory) + .WithName("GetListeningHistory") + .WithSummary("Get a user's listening history") + .Produces>() + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id:long}/listening-stats", GetListeningStats) - .WithName("GetListeningStats") - .WithSummary("Get a user's listening stats for the current week and streak") - .RequireAuthorization() - .Produces() - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status403Forbidden); + group.MapGet("/{id:long}/listening-stats", GetListeningStats) + .WithName("GetListeningStats") + .WithSummary("Get a user's listening stats for the current week and streak") + .RequireAuthorization() + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); - group.MapGet("/{id:long}/last-listened-track", GetLastTrackListenedByUserId) - .WithName("GetLastTrackListenedByUserId") - .WithSummary("Get the last track a user listened to") - .Produces() - .Produces(StatusCodes.Status204NoContent); + group.MapGet("/{id:long}/last-listened-track", GetLastTrackListenedByUserId) + .WithName("GetLastTrackListenedByUserId") + .WithSummary("Get the last track a user listened to") + .Produces() + .Produces(StatusCodes.Status204NoContent); - group.MapGet("/{id:long}/profile", GetUserProfile) - .WithName("GetUserProfile") - .WithSummary("Get a user's public profile, including follower counts") - .Produces() - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:long}/profile", GetUserProfile) + .WithName("GetUserProfile") + .WithSummary("Get a user's public profile, including follower counts") + .Produces() + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id:long}/followers", GetFollowers) - .AddEndpointFilter>() - .WithName("GetUserFollowers") - .WithSummary("Get the users following a user. Visible to the user and to mutual followers") - .Produces>() - .Produces(StatusCodes.Status403Forbidden) - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:long}/followers", GetFollowers) + .AddEndpointFilter>() + .WithName("GetUserFollowers") + .WithSummary("Get the users following a user. Visible to the user and to mutual followers") + .Produces>() + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id:long}/following", GetFollowing) - .AddEndpointFilter>() - .WithName("GetUserFollowing") - .WithSummary("Get the users a user follows") - .Produces>() - .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id:long}/following", GetFollowing) + .AddEndpointFilter>() + .WithName("GetUserFollowing") + .WithSummary("Get the users a user follows") + .Produces>() + .Produces(StatusCodes.Status404NotFound); - group.MapGet("/{id:long}/is-following", IsFollowing) - .WithName("IsFollowingUser") - .WithSummary("Check whether the current user follows another user") - .RequireAuthorization() - .Produces() - .Produces(StatusCodes.Status401Unauthorized); + group.MapGet("/{id:long}/is-following", IsFollowing) + .WithName("IsFollowingUser") + .WithSummary("Check whether the current user follows another user") + .RequireAuthorization() + .Produces() + .Produces(StatusCodes.Status401Unauthorized); - group.MapPost("/{id:long}/follow", FollowUser) - .WithName("FollowUser") - .WithSummary("Follow a user") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound) - .ProducesValidationProblem(); + group.MapPost("/{id:long}/follow", FollowUser) + .WithName("FollowUser") + .WithSummary("Follow a user") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .ProducesValidationProblem(); - group.MapDelete("/{id:long}/follow", UnfollowUser) - .WithName("UnfollowUser") - .WithSummary("Unfollow a user") - .RequireAuthorization() - .Produces(StatusCodes.Status204NoContent) - .Produces(StatusCodes.Status401Unauthorized); + group.MapDelete("/{id:long}/follow", UnfollowUser) + .WithName("UnfollowUser") + .WithSummary("Unfollow a user") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized); - return app; - } + return app; + } - private static async Task GetUsers( - IMediator mediator, - CurrentUser currentUser, - string? usernameSearch, - [AsParameters] PageQuery page, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetUsersQuery(page.PageNumber, page.PageSize, usernameSearch, currentUser.Id), cancellationToken); - return Results.Ok(result); - } + private static async Task GetUsers( + IMediator mediator, + CurrentUser currentUser, + string? usernameSearch, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetUsersQuery(page.PageNumber, page.PageSize, usernameSearch, currentUser.Id), cancellationToken); + return Results.Ok(result); + } - private static async Task GetUserById( - IMediator mediator, - long id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetUserByIdQuery(id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetUserById( + IMediator mediator, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetUserByIdQuery(id), cancellationToken); + return result.ToHttpResult(); + } - private static async Task GetListeningHistory( - IMediator mediator, - long id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetListeningHistoryQuery(id), cancellationToken); - return Results.Ok(result); - } + private static async Task GetListeningHistory( + IMediator mediator, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetListeningHistoryQuery(id), cancellationToken); + return Results.Ok(result); + } - private static async Task GetListeningStats( - IMediator mediator, - CurrentUser currentUser, - long id, - CancellationToken cancellationToken) - { - if (currentUser.RequiredId != id) - return Results.Problem(statusCode: StatusCodes.Status403Forbidden); + private static async Task GetListeningStats( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken) + { + if (currentUser.RequiredId != id) + return Results.Problem(statusCode: StatusCodes.Status403Forbidden); - var result = await mediator.Send(new GetListeningStatsQuery(id), cancellationToken); - return Results.Ok(result); - } + var result = await mediator.Send(new GetListeningStatsQuery(id), cancellationToken); + return Results.Ok(result); + } - private static async Task GetLastTrackListenedByUserId( - IMediator mediator, - long id, - CancellationToken cancellationToken) - { - var track = await mediator.Send(new GetLastTrackListenedByUserIdQuery(id), cancellationToken); - return track == null ? Results.NoContent() : Results.Ok(track); - } + private static async Task GetLastTrackListenedByUserId( + IMediator mediator, + long id, + CancellationToken cancellationToken) + { + var track = await mediator.Send(new GetLastTrackListenedByUserIdQuery(id), cancellationToken); + return track == null ? Results.NoContent() : Results.Ok(track); + } - private static async Task GetUserProfile( - IMediator mediator, - CurrentUser currentUser, - long id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetUserProfileQuery(id, currentUser.Id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetUserProfile( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetUserProfileQuery(id, currentUser.Id), cancellationToken); + return result.ToHttpResult(); + } - private static async Task GetFollowers( - IMediator mediator, - CurrentUser currentUser, - long id, - [AsParameters] PageQuery page, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetUserFollowersQuery(currentUser.Id, id, page.PageNumber, page.PageSize), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetFollowers( + IMediator mediator, + CurrentUser currentUser, + long id, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetUserFollowersQuery(currentUser.Id, id, page.PageNumber, page.PageSize), cancellationToken); + return result.ToHttpResult(); + } - private static async Task GetFollowing( - IMediator mediator, - CurrentUser currentUser, - long id, - [AsParameters] PageQuery page, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetUserFollowingQuery(currentUser.Id, id, page.PageNumber, page.PageSize), cancellationToken); - return result.ToHttpResult(); - } + private static async Task GetFollowing( + IMediator mediator, + CurrentUser currentUser, + long id, + [AsParameters] PageQuery page, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetUserFollowingQuery(currentUser.Id, id, page.PageNumber, page.PageSize), cancellationToken); + return result.ToHttpResult(); + } - private static async Task IsFollowing( - IMediator mediator, - CurrentUser currentUser, - long id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new IsFollowingUserQuery(currentUser.RequiredId, id), cancellationToken); - return Results.Ok(result); - } + private static async Task IsFollowing( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new IsFollowingUserQuery(currentUser.RequiredId, id), cancellationToken); + return Results.Ok(result); + } - private static async Task FollowUser( - IMediator mediator, - CurrentUser currentUser, - long id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new FollowUserCommand(currentUser.RequiredId, id), cancellationToken); - return result.ToHttpResult(); - } + private static async Task FollowUser( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new FollowUserCommand(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } - private static async Task UnfollowUser( - IMediator mediator, - CurrentUser currentUser, - long id, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new UnfollowUserCommand(currentUser.RequiredId, id), cancellationToken); - return result.ToHttpResult(); + private static async Task UnfollowUser( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new UnfollowUserCommand(currentUser.RequiredId, id), cancellationToken); + return result.ToHttpResult(); + } } } diff --git a/backend/Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs b/backend/Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs index d6e75ff..4e5d2f2 100644 --- a/backend/Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs +++ b/backend/Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs @@ -1,43 +1,44 @@ using ErrorOr; using IResult = Microsoft.AspNetCore.Http.IResult; -namespace Musify.Api.Extensions; - -public static class ErrorOrHttpExtensions +namespace Musify.Api.Extensions { - public static IResult ToHttpResult(this ErrorOr result) + public static class ErrorOrHttpExtensions { - if (result.IsError) - return Problem(result.Errors); - - return result.Value is Success - ? Results.NoContent() - : Results.Ok(result.Value); - } - - public static IResult ToCreatedResult(this ErrorOr result, Func locationFactory) => - result.IsError - ? Problem(result.Errors) - : Results.Created(locationFactory(result.Value), result.Value); - - private static IResult Problem(List errors) - { - if (errors.All(e => e.Type == ErrorType.Validation)) - return Results.ValidationProblem(ToValidationDictionary(errors)); - - var error = errors[0]; - return error.Type switch + public static IResult ToHttpResult(this ErrorOr result) { - ErrorType.NotFound => Results.Problem(error.Description, statusCode: StatusCodes.Status404NotFound), - ErrorType.Unauthorized => Results.Unauthorized(), - ErrorType.Forbidden => Results.Problem(error.Description, statusCode: StatusCodes.Status403Forbidden), - ErrorType.Conflict => Results.Problem(error.Description, statusCode: StatusCodes.Status409Conflict), - _ => Results.Problem(error.Description) - }; - } + if (result.IsError) + return Problem(result.Errors); - private static Dictionary ToValidationDictionary(IEnumerable errors) => - errors - .GroupBy(e => string.IsNullOrEmpty(e.Code) ? "general" : e.Code) - .ToDictionary(g => g.Key, g => g.Select(e => e.Description).ToArray()); + return result.Value is Success + ? Results.NoContent() + : Results.Ok(result.Value); + } + + public static IResult ToCreatedResult(this ErrorOr result, Func locationFactory) => + result.IsError + ? Problem(result.Errors) + : Results.Created(locationFactory(result.Value), result.Value); + + private static IResult Problem(List errors) + { + if (errors.All(e => e.Type == ErrorType.Validation)) + return Results.ValidationProblem(ToValidationDictionary(errors)); + + var error = errors[0]; + return error.Type switch + { + ErrorType.NotFound => Results.Problem(error.Description, statusCode: StatusCodes.Status404NotFound), + ErrorType.Unauthorized => Results.Unauthorized(), + ErrorType.Forbidden => Results.Problem(error.Description, statusCode: StatusCodes.Status403Forbidden), + ErrorType.Conflict => Results.Problem(error.Description, statusCode: StatusCodes.Status409Conflict), + _ => Results.Problem(error.Description) + }; + } + + private static Dictionary ToValidationDictionary(IEnumerable errors) => + errors + .GroupBy(e => string.IsNullOrEmpty(e.Code) ? "general" : e.Code) + .ToDictionary(g => g.Key, g => g.Select(e => e.Description).ToArray()); + } } diff --git a/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs b/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs index da5c6d1..b5e0084 100644 --- a/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs +++ b/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs @@ -8,22 +8,23 @@ using Musify.Infrastructure.MassTransit; using Musify.Infrastructure.Persistence; using Musify.Infrastructure.Services; -namespace Musify.Api.Extensions; - -public static class ServiceCollectionExtensions +namespace Musify.Api.Extensions { - public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration configuration) + public static class ServiceCollectionExtensions { - services.AddApplication(configuration); - services.AddDatabase(configuration); - services.AddStorageService(configuration); - services.AddStreamTicketService(configuration); - services.AddMassTransitClient(configuration); - services.AddAuthenticationConfiguration(configuration); - services.AddOpenApiConfiguration(); - services.AddScalarConfiguration(); - services.AddValidatorsFromAssemblyContaining(); + public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration configuration) + { + services.AddApplication(configuration); + services.AddDatabase(configuration); + services.AddStorageService(configuration); + services.AddStreamTicketService(configuration); + services.AddMassTransitClient(configuration); + services.AddAuthenticationConfiguration(configuration); + services.AddOpenApiConfiguration(); + services.AddScalarConfiguration(); + services.AddValidatorsFromAssemblyContaining(); - return services; + return services; + } } } diff --git a/backend/Hosts/Musify.Api/Filters/ValidationFilter.cs b/backend/Hosts/Musify.Api/Filters/ValidationFilter.cs index 04cadad..3aeb2bd 100644 --- a/backend/Hosts/Musify.Api/Filters/ValidationFilter.cs +++ b/backend/Hosts/Musify.Api/Filters/ValidationFilter.cs @@ -1,20 +1,21 @@ using FluentValidation; -namespace Musify.Api.Filters; - -public sealed class ValidationFilter(IValidator validator) : IEndpointFilter where T : class +namespace Musify.Api.Filters { - public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + public sealed class ValidationFilter(IValidator validator) : IEndpointFilter where T : class { - var argument = context.Arguments.OfType().FirstOrDefault(); + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var argument = context.Arguments.OfType().FirstOrDefault(); - if (argument == null) - return await next(context); + if (argument == null) + return await next(context); - var result = await validator.ValidateAsync(argument, context.HttpContext.RequestAborted); + var result = await validator.ValidateAsync(argument, context.HttpContext.RequestAborted); - return result.IsValid - ? await next(context) - : Results.ValidationProblem(result.ToDictionary()); + return result.IsValid + ? await next(context) + : Results.ValidationProblem(result.ToDictionary()); + } } } diff --git a/backend/Hosts/Musify.Api/Models/GenreParameter.cs b/backend/Hosts/Musify.Api/Models/GenreParameter.cs index 8539c14..e866882 100644 --- a/backend/Hosts/Musify.Api/Models/GenreParameter.cs +++ b/backend/Hosts/Musify.Api/Models/GenreParameter.cs @@ -1,19 +1,20 @@ using Musify.Domain.ValueObjects; -namespace Musify.Api.Models; - -/// Query-string binding for a genre name (case-insensitive); unknown names are rejected with a 400. -public readonly record struct GenreParameter(Genre Value) +namespace Musify.Api.Models { - public static bool TryParse(string? value, out GenreParameter result) + /// Query-string binding for a genre name (case-insensitive); unknown names are rejected with a 400. + public readonly record struct GenreParameter(Genre Value) { - if (Enum.TryParse(value, ignoreCase: true, out var genre) && Enum.IsDefined(genre)) + public static bool TryParse(string? value, out GenreParameter result) { - result = new GenreParameter(genre); - return true; - } + if (Enum.TryParse(value, ignoreCase: true, out var genre) && Enum.IsDefined(genre)) + { + result = new GenreParameter(genre); + return true; + } - result = default; - return false; + result = default; + return false; + } } } diff --git a/backend/Hosts/Musify.Api/Models/PageQuery.cs b/backend/Hosts/Musify.Api/Models/PageQuery.cs index 888a064..2c21750 100644 --- a/backend/Hosts/Musify.Api/Models/PageQuery.cs +++ b/backend/Hosts/Musify.Api/Models/PageQuery.cs @@ -1,9 +1,10 @@ using Microsoft.AspNetCore.Mvc; using Musify.Application.Shared; -namespace Musify.Api.Models; - -/// Query-string binding for , keeping the camelCase parameter names of the API. -public record PageQuery( - [property: FromQuery(Name = "pageNumber")] int PageNumber = 1, - [property: FromQuery(Name = "pageSize")] int PageSize = 20); +namespace Musify.Api.Models +{ + /// Query-string binding for , keeping the camelCase parameter names of the API. + public record PageQuery( + [property: FromQuery(Name = "pageNumber")] int PageNumber = 1, + [property: FromQuery(Name = "pageSize")] int PageSize = 20); +} diff --git a/backend/Hosts/Musify.Api/OpenApi/OpenApiExtensions.cs b/backend/Hosts/Musify.Api/OpenApi/OpenApiExtensions.cs index 6f00c38..8f1751c 100644 --- a/backend/Hosts/Musify.Api/OpenApi/OpenApiExtensions.cs +++ b/backend/Hosts/Musify.Api/OpenApi/OpenApiExtensions.cs @@ -1,12 +1,13 @@ -namespace Musify.Api.OpenApi; - -public static class OpenApiExtensions +namespace Musify.Api.OpenApi { - public static IServiceCollection AddOpenApiConfiguration(this IServiceCollection services) + public static class OpenApiExtensions { - services.AddOpenApi(); - services.ConfigureOptions(); + public static IServiceCollection AddOpenApiConfiguration(this IServiceCollection services) + { + services.AddOpenApi(); + services.ConfigureOptions(); - return services; + return services; + } } } diff --git a/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs b/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs index a1ae1ec..acc2c72 100644 --- a/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs +++ b/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs @@ -4,88 +4,89 @@ using Microsoft.Extensions.Options; using Microsoft.OpenApi; using Musify.Api.Authentication; -namespace Musify.Api.OpenApi; - -public sealed class OpenApiOptionsSetup(IOptions options) - : IConfigureNamedOptions +namespace Musify.Api.OpenApi { - internal const string SecuritySchemeId = "OAuth2"; - - private readonly AuthenticationConfiguration configuration = options.Value; - - public void Configure(string? name, OpenApiOptions options) => Configure(options); - - public void Configure(OpenApiOptions options) + public sealed class OpenApiOptionsSetup(IOptions options) + : IConfigureNamedOptions { - options.AddDocumentTransformer((document, _, _) => + internal const string SecuritySchemeId = "OAuth2"; + + private readonly AuthenticationConfiguration configuration = options.Value; + + public void Configure(string? name, OpenApiOptions options) => Configure(options); + + public void Configure(OpenApiOptions options) { - document.Components ??= new OpenApiComponents(); - document.Components.SecuritySchemes ??= new Dictionary(); - document.Components.SecuritySchemes[SecuritySchemeId] = new OpenApiSecurityScheme + options.AddDocumentTransformer((document, _, _) => { - Type = SecuritySchemeType.OAuth2, - Flows = new OpenApiOAuthFlows + document.Components ??= new OpenApiComponents(); + document.Components.SecuritySchemes ??= new Dictionary(); + document.Components.SecuritySchemes[SecuritySchemeId] = new OpenApiSecurityScheme { - AuthorizationCode = new OpenApiOAuthFlow + Type = SecuritySchemeType.OAuth2, + Flows = new OpenApiOAuthFlows { - AuthorizationUrl = new Uri(configuration.AuthorizationEndpoint), - TokenUrl = new Uri(configuration.TokenEndpoint), - Scopes = configuration.Scopes.ToDictionary(scope => scope, scope => scope), + AuthorizationCode = new OpenApiOAuthFlow + { + AuthorizationUrl = new Uri(configuration.AuthorizationEndpoint), + TokenUrl = new Uri(configuration.TokenEndpoint), + Scopes = configuration.Scopes.ToDictionary(scope => scope, scope => scope), + }, }, - }, - }; + }; - return Task.CompletedTask; - }); - - options.AddSchemaTransformer((schema, context, _) => - { - // Every long the API returns is a user id, serialized as a string (JS loses precision above 2^53). - // Request DTOs only carry byte sizes, which may be sent as numbers or strings. - if (context.JsonPropertyInfo is { } property - && (property.PropertyType == typeof(long) || property.PropertyType == typeof(long?))) - { - var isRequest = property.DeclaringType?.Name.EndsWith("Request", StringComparison.Ordinal) == true; - schema.Type = isRequest ? JsonSchemaType.Integer | JsonSchemaType.String : JsonSchemaType.String; - if (property.PropertyType == typeof(long?)) - schema.Type |= JsonSchemaType.Null; - schema.Format = isRequest ? "int64" : null; - } - - return Task.CompletedTask; - }); - - options.AddOperationTransformer((operation, context, _) => - { - // Route parameters constrained with :long are user ids, which travel as strings like every other long. - foreach (var parameter in operation.Parameters?.OfType() ?? []) - { - if (parameter.Schema is OpenApiSchema { Type: JsonSchemaType.Integer, Format: "int64" } schema) - { - schema.Type = JsonSchemaType.String; - schema.Format = null; - } - } - - var requiresAuthorization = context.Description.ActionDescriptor.EndpointMetadata - .OfType() - .Any(); - - var allowsAnonymous = context.Description.ActionDescriptor.EndpointMetadata - .OfType() - .Any(); - - if (!requiresAuthorization || allowsAnonymous) return Task.CompletedTask; - - operation.Security ??= new List(); - operation.Security.Add(new OpenApiSecurityRequirement - { - [new OpenApiSecuritySchemeReference(SecuritySchemeId, context.Document)] = - configuration.Scopes.ToList(), }); - return Task.CompletedTask; - }); + options.AddSchemaTransformer((schema, context, _) => + { + // Every long the API returns is a user id, serialized as a string (JS loses precision above 2^53). + // Request DTOs only carry byte sizes, which may be sent as numbers or strings. + if (context.JsonPropertyInfo is { } property + && (property.PropertyType == typeof(long) || property.PropertyType == typeof(long?))) + { + var isRequest = property.DeclaringType?.Name.EndsWith("Request", StringComparison.Ordinal) == true; + schema.Type = isRequest ? JsonSchemaType.Integer | JsonSchemaType.String : JsonSchemaType.String; + if (property.PropertyType == typeof(long?)) + schema.Type |= JsonSchemaType.Null; + schema.Format = isRequest ? "int64" : null; + } + + return Task.CompletedTask; + }); + + options.AddOperationTransformer((operation, context, _) => + { + // Route parameters constrained with :long are user ids, which travel as strings like every other long. + foreach (var parameter in operation.Parameters?.OfType() ?? []) + { + if (parameter.Schema is OpenApiSchema { Type: JsonSchemaType.Integer, Format: "int64" } schema) + { + schema.Type = JsonSchemaType.String; + schema.Format = null; + } + } + + var requiresAuthorization = context.Description.ActionDescriptor.EndpointMetadata + .OfType() + .Any(); + + var allowsAnonymous = context.Description.ActionDescriptor.EndpointMetadata + .OfType() + .Any(); + + if (!requiresAuthorization || allowsAnonymous) + return Task.CompletedTask; + + operation.Security ??= new List(); + operation.Security.Add(new OpenApiSecurityRequirement + { + [new OpenApiSecuritySchemeReference(SecuritySchemeId, context.Document)] = + configuration.Scopes.ToList(), + }); + + return Task.CompletedTask; + }); + } } } diff --git a/backend/Hosts/Musify.Api/Scalar/ScalarExtensions.cs b/backend/Hosts/Musify.Api/Scalar/ScalarExtensions.cs index 13f2db8..f1aba32 100644 --- a/backend/Hosts/Musify.Api/Scalar/ScalarExtensions.cs +++ b/backend/Hosts/Musify.Api/Scalar/ScalarExtensions.cs @@ -1,11 +1,12 @@ -namespace Musify.Api.Scalar; - -public static class ScalarExtensions +namespace Musify.Api.Scalar { - public static IServiceCollection AddScalarConfiguration(this IServiceCollection services) + public static class ScalarExtensions { - services.ConfigureOptions(); + public static IServiceCollection AddScalarConfiguration(this IServiceCollection services) + { + services.ConfigureOptions(); - return services; + return services; + } } } diff --git a/backend/Hosts/Musify.Api/Scalar/ScalarOptionsSetup.cs b/backend/Hosts/Musify.Api/Scalar/ScalarOptionsSetup.cs index 05e77b6..6f33eef 100644 --- a/backend/Hosts/Musify.Api/Scalar/ScalarOptionsSetup.cs +++ b/backend/Hosts/Musify.Api/Scalar/ScalarOptionsSetup.cs @@ -3,29 +3,30 @@ using Musify.Api.Authentication; using Musify.Api.OpenApi; using Scalar.AspNetCore; -namespace Musify.Api.Scalar; - -public sealed class ScalarOptionsSetup(IOptions options) - : IConfigureOptions +namespace Musify.Api.Scalar { - private readonly AuthenticationConfiguration configuration = options.Value; - - public void Configure(ScalarOptions scalarOptions) + public sealed class ScalarOptionsSetup(IOptions options) + : IConfigureOptions { - scalarOptions - .WithTitle("Musify API") - .AddPreferredSecuritySchemes(OpenApiOptionsSetup.SecuritySchemeId) - .AddAuthorizationCodeFlow(OpenApiOptionsSetup.SecuritySchemeId, flow => - { - flow.ClientId = configuration.ClientId; - flow.ClientSecret = configuration.ClientSecret; - flow.AuthorizationUrl = configuration.AuthorizationEndpoint; - flow.TokenUrl = configuration.TokenEndpoint; - flow.Pkce = Pkce.Sha256; - flow.SelectedScopes = configuration.Scopes; - flow.RedirectUri = configuration.ScalarRedirectUri; - flow.RefreshUrl = configuration.TokenEndpoint; - }) - .HideModels(); + private readonly AuthenticationConfiguration configuration = options.Value; + + public void Configure(ScalarOptions scalarOptions) + { + scalarOptions + .WithTitle("Musify API") + .AddPreferredSecuritySchemes(OpenApiOptionsSetup.SecuritySchemeId) + .AddAuthorizationCodeFlow(OpenApiOptionsSetup.SecuritySchemeId, flow => + { + flow.ClientId = configuration.ClientId; + flow.ClientSecret = configuration.ClientSecret; + flow.AuthorizationUrl = configuration.AuthorizationEndpoint; + flow.TokenUrl = configuration.TokenEndpoint; + flow.Pkce = Pkce.Sha256; + flow.SelectedScopes = configuration.Scopes; + flow.RedirectUri = configuration.ScalarRedirectUri; + flow.RefreshUrl = configuration.TokenEndpoint; + }) + .HideModels(); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/AddTracksRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/AddTracksRequestValidator.cs index d3466e5..92f8990 100644 --- a/backend/Hosts/Musify.Api/Validators/AddTracksRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/AddTracksRequestValidator.cs @@ -1,20 +1,21 @@ using FluentValidation; using Musify.Api.DataTransferObjects; -namespace Musify.Api.Validators; - -public sealed class AddTracksRequestValidator : AbstractValidator +namespace Musify.Api.Validators { - public const int MaxTracksPerRequest = 500; - - public AddTracksRequestValidator() + public sealed class AddTracksRequestValidator : AbstractValidator { - RuleFor(x => x.TrackIds) - .NotEmpty() - .Must(ids => ids.Count <= MaxTracksPerRequest) - .WithMessage($"At most {MaxTracksPerRequest} tracks can be added at once."); + public const int MaxTracksPerRequest = 500; - RuleForEach(x => x.TrackIds) - .NotEmpty(); + public AddTracksRequestValidator() + { + RuleFor(x => x.TrackIds) + .NotEmpty() + .Must(ids => ids.Count <= MaxTracksPerRequest) + .WithMessage($"At most {MaxTracksPerRequest} tracks can be added at once."); + + RuleForEach(x => x.TrackIds) + .NotEmpty(); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs index 0bb90b2..f65887d 100644 --- a/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Albums/CreateAlbumRequestValidator.cs @@ -1,24 +1,25 @@ using FluentValidation; using Musify.Api.DataTransferObjects.Albums; -namespace Musify.Api.Validators.Albums; - -public sealed class CreateAlbumRequestValidator : AbstractValidator +namespace Musify.Api.Validators.Albums { - public CreateAlbumRequestValidator() + public sealed class CreateAlbumRequestValidator : AbstractValidator { - RuleFor(x => x.Title) - .NotEmpty() - .MaximumLength(Limits.AlbumTitle); + public CreateAlbumRequestValidator() + { + RuleFor(x => x.Title) + .NotEmpty() + .MaximumLength(Limits.AlbumTitle); - RuleFor(x => x.Description) - .MaximumLength(Limits.Description); + RuleFor(x => x.Description) + .MaximumLength(Limits.Description); - RuleFor(x => x.ReleaseYear) - .InclusiveBetween(Limits.EarliestReleaseYear, Limits.LatestReleaseYear) - .When(x => x.ReleaseYear.HasValue); + RuleFor(x => x.ReleaseYear) + .InclusiveBetween(Limits.EarliestReleaseYear, Limits.LatestReleaseYear) + .When(x => x.ReleaseYear.HasValue); - RuleFor(x => x.PictureIntentId) - .NotEmpty(); + RuleFor(x => x.PictureIntentId) + .NotEmpty(); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumRequestValidator.cs index 91620c6..e9b6458 100644 --- a/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Albums/UpdateAlbumRequestValidator.cs @@ -1,21 +1,22 @@ using FluentValidation; using Musify.Api.DataTransferObjects.Albums; -namespace Musify.Api.Validators.Albums; - -public sealed class UpdateAlbumRequestValidator : AbstractValidator +namespace Musify.Api.Validators.Albums { - public UpdateAlbumRequestValidator() + public sealed class UpdateAlbumRequestValidator : AbstractValidator { - RuleFor(x => x.Title) - .NotEmpty() - .MaximumLength(Limits.AlbumTitle); + public UpdateAlbumRequestValidator() + { + RuleFor(x => x.Title) + .NotEmpty() + .MaximumLength(Limits.AlbumTitle); - RuleFor(x => x.Description) - .MaximumLength(Limits.Description); + RuleFor(x => x.Description) + .MaximumLength(Limits.Description); - RuleFor(x => x.ReleaseYear) - .InclusiveBetween(Limits.EarliestReleaseYear, Limits.LatestReleaseYear) - .When(x => x.ReleaseYear.HasValue); + RuleFor(x => x.ReleaseYear) + .InclusiveBetween(Limits.EarliestReleaseYear, Limits.LatestReleaseYear) + .When(x => x.ReleaseYear.HasValue); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/Limits.cs b/backend/Hosts/Musify.Api/Validators/Limits.cs index ac782d8..30f91fb 100644 --- a/backend/Hosts/Musify.Api/Validators/Limits.cs +++ b/backend/Hosts/Musify.Api/Validators/Limits.cs @@ -1,12 +1,13 @@ -namespace Musify.Api.Validators; - -public static class Limits +namespace Musify.Api.Validators { - public const int TrackTitle = 200; - public const int AlbumTitle = 200; - public const int PlayListName = 50; - public const int Description = 256; - public const int EarliestReleaseYear = 1877; + public static class Limits + { + public const int TrackTitle = 200; + public const int AlbumTitle = 200; + public const int PlayListName = 50; + public const int Description = 256; + public const int EarliestReleaseYear = 1877; - public static int LatestReleaseYear => DateTime.UtcNow.Year + 1; + public static int LatestReleaseYear => DateTime.UtcNow.Year + 1; + } } diff --git a/backend/Hosts/Musify.Api/Validators/PageQueryValidator.cs b/backend/Hosts/Musify.Api/Validators/PageQueryValidator.cs index 2ae656a..c144a7d 100644 --- a/backend/Hosts/Musify.Api/Validators/PageQueryValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/PageQueryValidator.cs @@ -2,16 +2,17 @@ using FluentValidation; using Musify.Api.Models; using Musify.Application.Shared; -namespace Musify.Api.Validators; - -public sealed class PageQueryValidator : AbstractValidator +namespace Musify.Api.Validators { - public PageQueryValidator() + public sealed class PageQueryValidator : AbstractValidator { - RuleFor(x => x.PageNumber) - .GreaterThanOrEqualTo(1); + public PageQueryValidator() + { + RuleFor(x => x.PageNumber) + .GreaterThanOrEqualTo(1); - RuleFor(x => x.PageSize) - .InclusiveBetween(1, PageRequest.MaxPageSize); + RuleFor(x => x.PageSize) + .InclusiveBetween(1, PageRequest.MaxPageSize); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/PictureUploadRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PictureUploadRequestValidator.cs index 15a9f40..d574a85 100644 --- a/backend/Hosts/Musify.Api/Validators/PictureUploadRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/PictureUploadRequestValidator.cs @@ -2,19 +2,20 @@ using FluentValidation; using Musify.Api.DataTransferObjects; using Musify.Application.Configuration; -namespace Musify.Api.Validators; - -public sealed class PictureUploadRequestValidator : AbstractValidator +namespace Musify.Api.Validators { - public PictureUploadRequestValidator(UploadIntentConfiguration uploadConfiguration) + public sealed class PictureUploadRequestValidator : AbstractValidator { - RuleFor(x => x.FileType) - .MustBeFileType(Uploads.PictureFileTypes); + public PictureUploadRequestValidator(UploadIntentConfiguration uploadConfiguration) + { + RuleFor(x => x.FileType) + .MustBeFileType(Uploads.PictureFileTypes); - RuleFor(x => x.ContentType) - .MustBeContentType(Uploads.PictureContentTypes); + RuleFor(x => x.ContentType) + .MustBeContentType(Uploads.PictureContentTypes); - RuleFor(x => x.ExpectedSizeBytes) - .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); + RuleFor(x => x.ExpectedSizeBytes) + .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs index 7695b3c..f2d854c 100644 --- a/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/PlayLists/CreatePlayListRequestValidator.cs @@ -1,20 +1,21 @@ using FluentValidation; using Musify.Api.DataTransferObjects.PlayLists; -namespace Musify.Api.Validators.PlayLists; - -public sealed class CreatePlayListRequestValidator : AbstractValidator +namespace Musify.Api.Validators.PlayLists { - public CreatePlayListRequestValidator() + public sealed class CreatePlayListRequestValidator : AbstractValidator { - RuleFor(x => x.Name) - .NotEmpty() - .MaximumLength(Limits.PlayListName); + public CreatePlayListRequestValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .MaximumLength(Limits.PlayListName); - RuleFor(x => x.Description) - .MaximumLength(Limits.Description); + RuleFor(x => x.Description) + .MaximumLength(Limits.Description); - RuleFor(x => x.Visibility) - .IsInEnum(); + RuleFor(x => x.Visibility) + .IsInEnum(); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs index 6ca36af..fd1116a 100644 --- a/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/PlayLists/UpdatePlayListRequestValidator.cs @@ -1,23 +1,24 @@ using FluentValidation; using Musify.Api.DataTransferObjects.PlayLists; -namespace Musify.Api.Validators.PlayLists; - -public sealed class UpdatePlayListRequestValidator : AbstractValidator +namespace Musify.Api.Validators.PlayLists { - public UpdatePlayListRequestValidator() + public sealed class UpdatePlayListRequestValidator : AbstractValidator { - RuleFor(x => x.Name) - .NotEmpty() - .MaximumLength(Limits.PlayListName) - .When(x => x.Name != null); + public UpdatePlayListRequestValidator() + { + RuleFor(x => x.Name) + .NotEmpty() + .MaximumLength(Limits.PlayListName) + .When(x => x.Name != null); - RuleFor(x => x.Description) - .MaximumLength(Limits.Description) - .When(x => x.Description != null); + RuleFor(x => x.Description) + .MaximumLength(Limits.Description) + .When(x => x.Description != null); - RuleFor(x => x.Visibility) - .IsInEnum() - .When(x => x.Visibility != null); + RuleFor(x => x.Visibility) + .IsInEnum() + .When(x => x.Visibility != null); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs index 266ba43..f9d9876 100644 --- a/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Tracks/CreateTrackRequestValidator.cs @@ -2,33 +2,34 @@ using FluentValidation; using Musify.Api.DataTransferObjects.Tracks; using Musify.Domain.ValueObjects; -namespace Musify.Api.Validators.Tracks; - -public sealed class CreateTrackRequestValidator : AbstractValidator +namespace Musify.Api.Validators.Tracks { - public CreateTrackRequestValidator() + public sealed class CreateTrackRequestValidator : AbstractValidator { - RuleFor(x => x.Title) - .NotEmpty() - .MaximumLength(Limits.TrackTitle); + public CreateTrackRequestValidator() + { + RuleFor(x => x.Title) + .NotEmpty() + .MaximumLength(Limits.TrackTitle); - RuleFor(x => x.PictureIntentId) - .NotEmpty(); + RuleFor(x => x.PictureIntentId) + .NotEmpty(); - RuleFor(x => x.AudioIntentId) - .NotEmpty(); + RuleFor(x => x.AudioIntentId) + .NotEmpty(); - RuleFor(x => x.Tags) - .NotEmpty() - .WithMessage("At least one tag is required."); + RuleFor(x => x.Tags) + .NotEmpty() + .WithMessage("At least one tag is required."); - RuleFor(x => x.Tags) - .Must(tags => GenreCompatibility.FindConflicts(tags).Count == 0) - .When(x => x.Tags is { Count: > 0 }) - .WithMessage(x => - { - var conflict = GenreCompatibility.FindConflicts(x.Tags).First(); - return $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible."; - }); + RuleFor(x => x.Tags) + .Must(tags => GenreCompatibility.FindConflicts(tags).Count == 0) + .When(x => x.Tags is { Count: > 0 }) + .WithMessage(x => + { + var conflict = GenreCompatibility.FindConflicts(x.Tags).First(); + return $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible."; + }); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/Tracks/RecordListeningProgressRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Tracks/RecordListeningProgressRequestValidator.cs index 518be4b..96e152e 100644 --- a/backend/Hosts/Musify.Api/Validators/Tracks/RecordListeningProgressRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Tracks/RecordListeningProgressRequestValidator.cs @@ -1,14 +1,15 @@ using FluentValidation; using Musify.Api.DataTransferObjects.Tracks; -namespace Musify.Api.Validators.Tracks; - -public sealed class RecordListeningProgressRequestValidator : AbstractValidator +namespace Musify.Api.Validators.Tracks { - public RecordListeningProgressRequestValidator() + public sealed class RecordListeningProgressRequestValidator : AbstractValidator { - RuleFor(x => x.PlayedSeconds) - .Must(seconds => double.IsFinite(seconds) && seconds >= 0) - .WithMessage("'Played Seconds' must be a non-negative number."); + public RecordListeningProgressRequestValidator() + { + RuleFor(x => x.PlayedSeconds) + .Must(seconds => double.IsFinite(seconds) && seconds >= 0) + .WithMessage("'Played Seconds' must be a non-negative number."); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs index ee2834e..3e78902 100644 --- a/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs @@ -2,28 +2,29 @@ using FluentValidation; using Musify.Api.DataTransferObjects.Tracks; using Musify.Application.Configuration; -namespace Musify.Api.Validators.Tracks; - -public sealed class RequestTrackUploadUrlsRequestValidator : AbstractValidator +namespace Musify.Api.Validators.Tracks { - public RequestTrackUploadUrlsRequestValidator(UploadIntentConfiguration uploadConfiguration) + public sealed class RequestTrackUploadUrlsRequestValidator : AbstractValidator { - RuleFor(x => x.PictureFileType) - .MustBeFileType(Uploads.PictureFileTypes); + public RequestTrackUploadUrlsRequestValidator(UploadIntentConfiguration uploadConfiguration) + { + RuleFor(x => x.PictureFileType) + .MustBeFileType(Uploads.PictureFileTypes); - RuleFor(x => x.PictureContentType) - .MustBeContentType(Uploads.PictureContentTypes); + RuleFor(x => x.PictureContentType) + .MustBeContentType(Uploads.PictureContentTypes); - RuleFor(x => x.AudioFileType) - .MustBeFileType(Uploads.AudioFileTypes); + RuleFor(x => x.AudioFileType) + .MustBeFileType(Uploads.AudioFileTypes); - RuleFor(x => x.AudioContentType) - .MustBeContentType(Uploads.AudioContentTypes); + RuleFor(x => x.AudioContentType) + .MustBeContentType(Uploads.AudioContentTypes); - RuleFor(x => x.ExpectedPictureSizeBytes) - .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); + RuleFor(x => x.ExpectedPictureSizeBytes) + .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); - RuleFor(x => x.ExpectedAudioSizeBytes) - .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); + RuleFor(x => x.ExpectedAudioSizeBytes) + .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); + } } } diff --git a/backend/Hosts/Musify.Api/Validators/Uploads.cs b/backend/Hosts/Musify.Api/Validators/Uploads.cs index 39ad9fc..c5ca018 100644 --- a/backend/Hosts/Musify.Api/Validators/Uploads.cs +++ b/backend/Hosts/Musify.Api/Validators/Uploads.cs @@ -1,42 +1,43 @@ using FluentValidation; -namespace Musify.Api.Validators; - -public static class Uploads +namespace Musify.Api.Validators { - public static readonly IReadOnlySet PictureFileTypes = - new HashSet(StringComparer.OrdinalIgnoreCase) { "jpg", "jpeg", "png", "webp" }; + public static class Uploads + { + public static readonly IReadOnlySet PictureFileTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) { "jpg", "jpeg", "png", "webp" }; - public static readonly IReadOnlySet PictureContentTypes = - new HashSet(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/webp" }; + public static readonly IReadOnlySet PictureContentTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/webp" }; - public static readonly IReadOnlySet AudioFileTypes = - new HashSet(StringComparer.OrdinalIgnoreCase) { "mp3", "wav", "flac", "ogg", "m4a", "aac" }; + public static readonly IReadOnlySet AudioFileTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) { "mp3", "wav", "flac", "ogg", "m4a", "aac" }; - public static readonly IReadOnlySet AudioContentTypes = - new HashSet(StringComparer.OrdinalIgnoreCase) - { - "audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav", "audio/wave", "audio/flac", "audio/x-flac", - "audio/ogg", "audio/mp4", "audio/x-m4a", "audio/m4a", "audio/aac" - }; + public static readonly IReadOnlySet AudioContentTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + "audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav", "audio/wave", "audio/flac", "audio/x-flac", + "audio/ogg", "audio/mp4", "audio/x-m4a", "audio/m4a", "audio/aac" + }; - public static IRuleBuilderOptions MustBeFileType( - this IRuleBuilder rule, IReadOnlySet allowed) => - rule - .NotEmpty() - .Length(1, 10) - .Matches("^[A-Za-z0-9]+$").WithMessage("'{PropertyName}' must contain only letters and digits.") - .Must(allowed.Contains).WithMessage("'{PropertyName}' must be one of: " + string.Join(", ", allowed) + "."); + public static IRuleBuilderOptions MustBeFileType( + this IRuleBuilder rule, IReadOnlySet allowed) => + rule + .NotEmpty() + .Length(1, 10) + .Matches("^[A-Za-z0-9]+$").WithMessage("'{PropertyName}' must contain only letters and digits.") + .Must(allowed.Contains).WithMessage("'{PropertyName}' must be one of: " + string.Join(", ", allowed) + "."); - public static IRuleBuilderOptions MustBeContentType( - this IRuleBuilder rule, IReadOnlySet allowed) => - rule - .NotEmpty() - .Must(allowed.Contains).WithMessage("'{PropertyName}' is not an allowed content type."); + public static IRuleBuilderOptions MustBeContentType( + this IRuleBuilder rule, IReadOnlySet allowed) => + rule + .NotEmpty() + .Must(allowed.Contains).WithMessage("'{PropertyName}' is not an allowed content type."); - public static IRuleBuilderOptions MustBeValidUploadSize( - this IRuleBuilder rule, long maxUploadBytes) => - rule - .Must(size => size is null || (size > 0 && size <= maxUploadBytes)) - .WithMessage($"'{{PropertyName}}' must be greater than 0 and at most {maxUploadBytes} bytes."); + public static IRuleBuilderOptions MustBeValidUploadSize( + this IRuleBuilder rule, long maxUploadBytes) => + rule + .Must(size => size is null || (size > 0 && size <= maxUploadBytes)) + .WithMessage($"'{{PropertyName}}' must be greater than 0 and at most {maxUploadBytes} bytes."); + } } diff --git a/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs b/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs index 3a27213..d458ae0 100644 --- a/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs +++ b/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs @@ -3,58 +3,59 @@ using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using Musify.StreamingGateway.Configuration; -namespace Musify.StreamingGateway.Authentication; - -public sealed record TicketValidationResult(string Prefix, long? MaxBytes) +namespace Musify.StreamingGateway.Authentication { - public static readonly TicketValidationResult Invalid = new(string.Empty, null); - - public bool IsValid => Prefix.Length > 0; -} - -public sealed class TicketValidator : IDisposable -{ - private readonly RSA rsa; - private readonly TokenValidationParameters validationParameters; - private readonly JsonWebTokenHandler handler = new(); - - public TicketValidator(StreamTicketValidationConfiguration configuration) + public sealed record TicketValidationResult(string Prefix, long? MaxBytes) { - rsa = RSA.Create(); - rsa.ImportFromPem(File.ReadAllText(configuration.PublicKeyPath)); + public static readonly TicketValidationResult Invalid = new(string.Empty, null); - validationParameters = new TokenValidationParameters + public bool IsValid => Prefix.Length > 0; + } + + public sealed class TicketValidator : IDisposable + { + private readonly RSA rsa; + private readonly TokenValidationParameters validationParameters; + private readonly JsonWebTokenHandler handler = new(); + + public TicketValidator(StreamTicketValidationConfiguration configuration) { - ValidateIssuer = true, - ValidIssuer = configuration.Issuer, - ValidateAudience = true, - ValidAudience = configuration.Audience, - ValidateLifetime = true, - ValidateIssuerSigningKey = true, - IssuerSigningKey = new RsaSecurityKey(rsa), - ValidAlgorithms = [SecurityAlgorithms.RsaSha256], - ClockSkew = TimeSpan.FromSeconds(30), - }; + rsa = RSA.Create(); + rsa.ImportFromPem(File.ReadAllText(configuration.PublicKeyPath)); + + validationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = configuration.Issuer, + ValidateAudience = true, + ValidAudience = configuration.Audience, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new RsaSecurityKey(rsa), + ValidAlgorithms = [SecurityAlgorithms.RsaSha256], + ClockSkew = TimeSpan.FromSeconds(30), + }; + } + + public async Task ValidateTicketAsync(string? token) + { + if (string.IsNullOrWhiteSpace(token)) + return TicketValidationResult.Invalid; + + var result = await handler.ValidateTokenAsync(token, validationParameters); + if (!result.IsValid) + return TicketValidationResult.Invalid; + + var prefix = result.ClaimsIdentity?.FindFirst("prefix")?.Value; + if (string.IsNullOrEmpty(prefix)) + return TicketValidationResult.Invalid; + + var maxBytesClaim = result.ClaimsIdentity?.FindFirst("maxBytes")?.Value; + var maxBytes = long.TryParse(maxBytesClaim, out var parsed) ? parsed : (long?)null; + + return new TicketValidationResult(prefix, maxBytes); + } + + public void Dispose() => rsa.Dispose(); } - - public async Task ValidateTicketAsync(string? token) - { - if (string.IsNullOrWhiteSpace(token)) - return TicketValidationResult.Invalid; - - var result = await handler.ValidateTokenAsync(token, validationParameters); - if (!result.IsValid) - return TicketValidationResult.Invalid; - - var prefix = result.ClaimsIdentity?.FindFirst("prefix")?.Value; - if (string.IsNullOrEmpty(prefix)) - return TicketValidationResult.Invalid; - - var maxBytesClaim = result.ClaimsIdentity?.FindFirst("maxBytes")?.Value; - var maxBytes = long.TryParse(maxBytesClaim, out var parsed) ? parsed : (long?)null; - - return new TicketValidationResult(prefix, maxBytes); - } - - public void Dispose() => rsa.Dispose(); } diff --git a/backend/Hosts/Musify.StreamingGateway/Configuration/StreamTicketValidationConfiguration.cs b/backend/Hosts/Musify.StreamingGateway/Configuration/StreamTicketValidationConfiguration.cs index 3d9a0ff..927e7f4 100644 --- a/backend/Hosts/Musify.StreamingGateway/Configuration/StreamTicketValidationConfiguration.cs +++ b/backend/Hosts/Musify.StreamingGateway/Configuration/StreamTicketValidationConfiguration.cs @@ -1,23 +1,24 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.StreamingGateway.Configuration; - -public sealed class StreamTicketValidationConfiguration +namespace Musify.StreamingGateway.Configuration { - public const string SectionName = "StreamTicket"; + public sealed class StreamTicketValidationConfiguration + { + public const string SectionName = "StreamTicket"; - [Required] - public required string PublicKeyPath { get; set; } + [Required] + public required string PublicKeyPath { get; set; } - [Required] - public string Audience { get; set; } = "media-gateway"; + [Required] + public string Audience { get; set; } = "media-gateway"; - [Required] - public string Issuer { get; set; } = "musify-webapi"; + [Required] + public string Issuer { get; set; } = "musify-webapi"; - public string QueryParameterName { get; set; } = "t"; + public string QueryParameterName { get; set; } = "t"; - public string HeaderName { get; set; } = "X-Stream-Ticket"; + public string HeaderName { get; set; } = "X-Stream-Ticket"; - public string MediaPathPrefix { get; set; } = "/media"; + public string MediaPathPrefix { get; set; } = "/media"; + } } diff --git a/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs b/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs index 3aae1f1..b4cabc1 100644 --- a/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs +++ b/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs @@ -3,112 +3,113 @@ using Microsoft.Net.Http.Headers; using Musify.StreamingGateway.Authentication; using Musify.StreamingGateway.Configuration; -namespace Musify.StreamingGateway.Middleware; - -public sealed class TicketValidationMiddleware( - RequestDelegate next, - TicketValidator ticketValidator, - StreamTicketValidationConfiguration options, - ILogger logger) +namespace Musify.StreamingGateway.Middleware { - public async Task InvokeAsync(HttpContext context) + public sealed class TicketValidationMiddleware( + RequestDelegate next, + TicketValidator ticketValidator, + StreamTicketValidationConfiguration options, + ILogger logger) { - var path = context.Request.Path; - - if (!path.StartsWithSegments(options.MediaPathPrefix, out var remaining)) + public async Task InvokeAsync(HttpContext context) { + var path = context.Request.Path; + + if (!path.StartsWithSegments(options.MediaPathPrefix, out var remaining)) + { + await next(context); + return; + } + + var token = context.Request.Query[options.QueryParameterName].ToString(); + if (string.IsNullOrEmpty(token)) + { + token = context.Request.Headers[options.HeaderName].ToString(); + } + + var validation = await ticketValidator.ValidateTicketAsync(token); + + if (!validation.IsValid) + { + logger.LogWarning("Rejected media request {Path}: missing or invalid ticket", path); + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + var prefix = validation.Prefix; + var objectKey = remaining.Value?.TrimStart('/') ?? string.Empty; + + if (HasTraversalSegment(objectKey)) + { + logger.LogWarning("Rejected media request {ObjectKey}: contains a path traversal segment", objectKey); + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + var prefixBoundary = prefix.EndsWith('/') ? prefix : prefix + "/"; + if (!string.Equals(objectKey, prefix, StringComparison.Ordinal) + && !objectKey.StartsWith(prefixBoundary, StringComparison.Ordinal)) + { + logger.LogWarning("Rejected media request {ObjectKey}: outside authorized prefix {Prefix}", objectKey, prefix); + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + + if (validation.MaxBytes is { } maxBytes && !TryClampRange(context, maxBytes)) + { + logger.LogInformation( + "Rejected media request {ObjectKey}: requested range is a suffix range or starts beyond the {MaxBytes}-byte preview limit", + objectKey, maxBytes); + context.Response.StatusCode = StatusCodes.Status416RangeNotSatisfiable; + context.Response.Headers[HeaderNames.ContentRange] = $"bytes */{maxBytes}"; + return; + } + + StripQueryParameter(context, options.QueryParameterName); await next(context); - return; } - var token = context.Request.Query[options.QueryParameterName].ToString(); - if (string.IsNullOrEmpty(token)) + private static bool TryClampRange(HttpContext context, long maxBytes) { - token = context.Request.Headers[options.HeaderName].ToString(); - } + var rangeHeader = context.Request.Headers.Range; + if (StringValues.IsNullOrEmpty(rangeHeader)) + { + context.Request.Headers.Range = $"bytes=0-{maxBytes - 1}"; + return true; + } - var validation = await ticketValidator.ValidateTicketAsync(token); + if (!RangeHeaderValue.TryParse(rangeHeader.ToString(), out var range) + || range.Ranges.Count != 1 + || !string.Equals(range.Unit.ToString(), "bytes", StringComparison.OrdinalIgnoreCase)) + { + context.Request.Headers.Range = $"bytes=0-{maxBytes - 1}"; + return true; + } - if (!validation.IsValid) - { - logger.LogWarning("Rejected media request {Path}: missing or invalid ticket", path); - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - return; - } + var requested = range.Ranges.Single(); + if (requested.From is null) + return false; - var prefix = validation.Prefix; - var objectKey = remaining.Value?.TrimStart('/') ?? string.Empty; + var start = requested.From.Value; + if (start >= maxBytes) + return false; - if (HasTraversalSegment(objectKey)) - { - logger.LogWarning("Rejected media request {ObjectKey}: contains a path traversal segment", objectKey); - context.Response.StatusCode = StatusCodes.Status400BadRequest; - return; - } - - var prefixBoundary = prefix.EndsWith('/') ? prefix : prefix + "/"; - if (!string.Equals(objectKey, prefix, StringComparison.Ordinal) - && !objectKey.StartsWith(prefixBoundary, StringComparison.Ordinal)) - { - logger.LogWarning("Rejected media request {ObjectKey}: outside authorized prefix {Prefix}", objectKey, prefix); - context.Response.StatusCode = StatusCodes.Status403Forbidden; - return; - } - - if (validation.MaxBytes is { } maxBytes && !TryClampRange(context, maxBytes)) - { - logger.LogInformation( - "Rejected media request {ObjectKey}: requested range is a suffix range or starts beyond the {MaxBytes}-byte preview limit", - objectKey, maxBytes); - context.Response.StatusCode = StatusCodes.Status416RangeNotSatisfiable; - context.Response.Headers[HeaderNames.ContentRange] = $"bytes */{maxBytes}"; - return; - } - - StripQueryParameter(context, options.QueryParameterName); - await next(context); - } - - private static bool TryClampRange(HttpContext context, long maxBytes) - { - var rangeHeader = context.Request.Headers.Range; - if (StringValues.IsNullOrEmpty(rangeHeader)) - { - context.Request.Headers.Range = $"bytes=0-{maxBytes - 1}"; + var end = requested.To is { } requestedEnd ? Math.Min(requestedEnd, maxBytes - 1) : maxBytes - 1; + context.Request.Headers.Range = $"bytes={start}-{end}"; return true; } - if (!RangeHeaderValue.TryParse(rangeHeader.ToString(), out var range) - || range.Ranges.Count != 1 - || !string.Equals(range.Unit.ToString(), "bytes", StringComparison.OrdinalIgnoreCase)) + private static bool HasTraversalSegment(string objectKey) => + objectKey.Split('/').Any(segment => segment is ".." or "."); + + private static void StripQueryParameter(HttpContext context, string name) { - context.Request.Headers.Range = $"bytes=0-{maxBytes - 1}"; - return true; + var remaining = context.Request.Query + .Where(pair => !string.Equals(pair.Key, name, StringComparison.Ordinal)) + .SelectMany(pair => pair.Value.Select(value => new KeyValuePair(pair.Key, value))) + .ToList(); + + context.Request.QueryString = QueryString.Create(remaining); } - - var requested = range.Ranges.Single(); - if (requested.From is null) - return false; - - var start = requested.From.Value; - if (start >= maxBytes) - return false; - - var end = requested.To is { } requestedEnd ? Math.Min(requestedEnd, maxBytes - 1) : maxBytes - 1; - context.Request.Headers.Range = $"bytes={start}-{end}"; - return true; - } - - private static bool HasTraversalSegment(string objectKey) => - objectKey.Split('/').Any(segment => segment is ".." or "."); - - private static void StripQueryParameter(HttpContext context, string name) - { - var remaining = context.Request.Query - .Where(pair => !string.Equals(pair.Key, name, StringComparison.Ordinal)) - .SelectMany(pair => pair.Value.Select(value => new KeyValuePair(pair.Key, value))) - .ToList(); - - context.Request.QueryString = QueryString.Create(remaining); } } diff --git a/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs b/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs index 0336538..c6fc742 100644 --- a/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs +++ b/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs @@ -3,64 +3,65 @@ 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); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs index 0a79070..ae9389e 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs @@ -9,121 +9,122 @@ using Musify.Application.Pictures.Responses; using NSubstitute; using Xunit; -namespace Musify.Api.Tests.Endpoints; - -[Collection(ApiCollection.Name)] -public sealed class AlbumEndpointsTests(ApiTestFixture fixture) +namespace Musify.Api.Tests.Endpoints { - private Task CreateUserAsync() => fixture.SeedUserAsync(); - - private async Task CreatePictureIntentAsync(HttpClient client) + [Collection(ApiCollection.Name)] + public sealed class AlbumEndpointsTests(ApiTestFixture fixture) { - fixture.StorageService - .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns("https://storage.musify.test/presigned-upload"); - fixture.StorageService - .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ObjectMetaData("image/webp", 1024)); + private Task CreateUserAsync() => fixture.SeedUserAsync(); - var response = await client.PostAsJsonAsync( - "/albums/upload-picture", - new PictureUploadRequest("webp", "image/webp"), - TestContext.Current.CancellationToken); - response.EnsureSuccessStatusCode(); + private async Task CreatePictureIntentAsync(HttpClient client) + { + fixture.StorageService + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://storage.musify.test/presigned-upload"); + fixture.StorageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); - var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - return body!.IntentId; - } + var response = await client.PostAsJsonAsync( + "/albums/upload-picture", + new PictureUploadRequest("webp", "image/webp"), + TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); - [Fact] - public async Task PostAlbums_NoAuthHeader_ReturnsUnauthorized() - { - var response = await fixture.CreateAnonymousClient() - .PostAsJsonAsync("/albums", new CreateAlbumRequest("Unauthorized Album", null, null, Guid.NewGuid()), TestContext.Current.CancellationToken); + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + return body!.IntentId; + } - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } + [Fact] + public async Task PostAlbums_NoAuthHeader_ReturnsUnauthorized() + { + var response = await fixture.CreateAnonymousClient() + .PostAsJsonAsync("/albums", new CreateAlbumRequest("Unauthorized Album", null, null, Guid.NewGuid()), TestContext.Current.CancellationToken); - [Fact] - public async Task PostAlbums_AuthenticatedExistingUser_ReturnsCreatedAlbum() - { - var userId = await CreateUserAsync(); - var client = fixture.CreateAuthenticatedClient(userId); - var pictureIntentId = await CreatePictureIntentAsync(client); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } - var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024, pictureIntentId), TestContext.Current.CancellationToken); + [Fact] + public async Task PostAlbums_AuthenticatedExistingUser_ReturnsCreatedAlbum() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); + var pictureIntentId = await CreatePictureIntentAsync(client); - 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); - } + var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024, pictureIntentId), TestContext.Current.CancellationToken); - [Fact] - public async Task PostAlbums_BlankTitle_ReturnsValidationProblem() - { - var userId = await CreateUserAsync(); - var client = fixture.CreateAuthenticatedClient(userId); + 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); + } - var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("", null, null, Guid.NewGuid()), TestContext.Current.CancellationToken); + [Fact] + public async Task PostAlbums_BlankTitle_ReturnsValidationProblem() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } + var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("", null, null, Guid.NewGuid()), TestContext.Current.CancellationToken); - [Fact] - public async Task PostAlbums_MissingPictureIntent_ReturnsValidationProblem() - { - var userId = await CreateUserAsync(); - var client = fixture.CreateAuthenticatedClient(userId); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } - var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("No Picture", null, null, Guid.Empty), TestContext.Current.CancellationToken); + [Fact] + public async Task PostAlbums_MissingPictureIntent_ReturnsValidationProblem() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } + var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("No Picture", null, null, Guid.Empty), TestContext.Current.CancellationToken); - [Fact] - public async Task GetAlbumById_AfterCreate_ReturnsIt() - { - var userId = await CreateUserAsync(); - var client = fixture.CreateAuthenticatedClient(userId); - var pictureIntentId = await CreatePictureIntentAsync(client); - var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Findable", null, null, pictureIntentId), TestContext.Current.CancellationToken)) - .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } - var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); + [Fact] + public async Task GetAlbumById_AfterCreate_ReturnsIt() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); + var pictureIntentId = await CreatePictureIntentAsync(client); + var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Findable", null, null, pictureIntentId), TestContext.Current.CancellationToken)) + .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } + var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); - [Fact] - public async Task DeleteAlbum_NotTheOwner_ReturnsForbidden() - { - var owner = await CreateUserAsync(); - var stranger = await CreateUserAsync(); - var ownerClient = fixture.CreateAuthenticatedClient(owner); - var pictureIntentId = await CreatePictureIntentAsync(ownerClient); - var created = await (await ownerClient.PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null, pictureIntentId), TestContext.Current.CancellationToken)) - .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } - var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); + [Fact] + public async Task DeleteAlbum_NotTheOwner_ReturnsForbidden() + { + var owner = await CreateUserAsync(); + var stranger = await CreateUserAsync(); + var ownerClient = fixture.CreateAuthenticatedClient(owner); + var pictureIntentId = await CreatePictureIntentAsync(ownerClient); + var created = await (await ownerClient.PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null, pictureIntentId), TestContext.Current.CancellationToken)) + .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - } + var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); - [Fact] - public async Task DeleteAlbum_Owner_ReturnsNoContentAndAlbumIsGone() - { - var userId = await CreateUserAsync(); - var client = fixture.CreateAuthenticatedClient(userId); - var pictureIntentId = await CreatePictureIntentAsync(client); - var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Deletable", null, null, pictureIntentId), TestContext.Current.CancellationToken)) - .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } - var deleteResponse = await client.DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); - var getResponse = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created.Id}", TestContext.Current.CancellationToken); + [Fact] + public async Task DeleteAlbum_Owner_ReturnsNoContentAndAlbumIsGone() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); + var pictureIntentId = await CreatePictureIntentAsync(client); + var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Deletable", null, null, pictureIntentId), TestContext.Current.CancellationToken)) + .Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); - Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode); + 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); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/ConfigEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/ConfigEndpointsTests.cs index 4f7f29c..41ec394 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/ConfigEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/ConfigEndpointsTests.cs @@ -4,21 +4,22 @@ using Musify.Api.Tests.TestSupport; using Musify.Application.Configuration.Responses; using Xunit; -namespace Musify.Api.Tests.Endpoints; - -[Collection(ApiCollection.Name)] -public sealed class ConfigEndpointsTests(ApiTestFixture fixture) +namespace Musify.Api.Tests.Endpoints { - [Fact] - public async Task GetPlaybackConfig_WithoutAuthentication_ReturnsConfiguredValues() + [Collection(ApiCollection.Name)] + public sealed class ConfigEndpointsTests(ApiTestFixture fixture) { - var client = fixture.CreateAnonymousClient(); + [Fact] + public async Task GetPlaybackConfig_WithoutAuthentication_ReturnsConfiguredValues() + { + var client = fixture.CreateAnonymousClient(); - var response = await client.GetAsync("/config/playback", TestContext.Current.CancellationToken); + var response = await client.GetAsync("/config/playback", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.NotNull(body); - Assert.False(body.AllowAnonymousListening); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(body); + Assert.False(body.AllowAnonymousListening); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs index 28def3b..f5fed4f 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs @@ -12,94 +12,95 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.Persistence; using Xunit; -namespace Musify.Api.Tests.Endpoints; - -[Collection(ApiCollection.Name)] -public sealed class GenreEndpointsTests(ApiTestFixture fixture) +namespace Musify.Api.Tests.Endpoints { - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + [Collection(ApiCollection.Name)] + public sealed class GenreEndpointsTests(ApiTestFixture fixture) { - Converters = { new JsonStringEnumConverter() } - }; - - private async Task SeedTrackAsync(Genre genre) - { - using var scope = fixture.Services.CreateScope(); - var database = scope.ServiceProvider.GetRequiredService(); - var owner = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "genre-owner", NormalizedName = "GENRE-OWNER" }; - var title = $"Genre track {Guid.NewGuid():N}"; - var track = new Track + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - OwnerUserId = owner.Id, - Owner = owner, - Tags = [new TrackTag { TrackId = Guid.Empty, Tag = genre }] + Converters = { new JsonStringEnumConverter() } }; - foreach (var tag in track.Tags) - tag.TrackId = track.Id; - database.Users.Add(owner); - database.Tracks.Add(track); - await database.SaveChangesAsync(TestContext.Current.CancellationToken); - return title; - } - [Fact] - public async Task GetGenres_Anonymous_ListsGenresInUse() - { - await SeedTrackAsync(Genre.Funk); + private async Task SeedTrackAsync(Genre genre) + { + using var scope = fixture.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + var owner = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "genre-owner", NormalizedName = "GENRE-OWNER" }; + var title = $"Genre track {Guid.NewGuid():N}"; + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + OwnerUserId = owner.Id, + Owner = owner, + Tags = [new TrackTag { TrackId = Guid.Empty, Tag = genre }] + }; + foreach (var tag in track.Tags) + tag.TrackId = track.Id; + database.Users.Add(owner); + database.Tracks.Add(track); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + return title; + } - var response = await fixture.CreateAnonymousClient().GetAsync("/genres", TestContext.Current.CancellationToken); + [Fact] + public async Task GetGenres_Anonymous_ListsGenresInUse() + { + await SeedTrackAsync(Genre.Funk); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); - Assert.Contains(body!, g => g.Genre == Genre.Funk && g.TrackCount >= 1); - } + var response = await fixture.CreateAnonymousClient().GetAsync("/genres", TestContext.Current.CancellationToken); - [Fact] - public async Task GetAvailableGenres_Anonymous_ListsEveryGenreWithIncompatibilities() - { - var response = await fixture.CreateAnonymousClient().GetAsync("/genres/available", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); + Assert.Contains(body!, g => g.Genre == Genre.Funk && g.TrackCount >= 1); + } - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); - Assert.Equal(Enum.GetValues().Length, body!.Count); - Assert.Contains(Genre.Lofi, body.Single(g => g.Genre == Genre.Metal).IncompatibleWith); - } + [Fact] + public async Task GetAvailableGenres_Anonymous_ListsEveryGenreWithIncompatibilities() + { + var response = await fixture.CreateAnonymousClient().GetAsync("/genres/available", TestContext.Current.CancellationToken); - [Theory] - [InlineData("Nope")] - [InlineData("999")] - public async Task GetTracks_UnknownGenre_ReturnsBadRequest(string genre) - { - var response = await fixture.CreateAnonymousClient().GetAsync($"/tracks?genre={genre}", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); + Assert.Equal(Enum.GetValues().Length, body!.Count); + Assert.Contains(Genre.Lofi, body.Single(g => g.Genre == Genre.Metal).IncompatibleWith); + } - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } + [Theory] + [InlineData("Nope")] + [InlineData("999")] + public async Task GetTracks_UnknownGenre_ReturnsBadRequest(string genre) + { + var response = await fixture.CreateAnonymousClient().GetAsync($"/tracks?genre={genre}", TestContext.Current.CancellationToken); - [Fact] - public async Task GetTracks_GenreQuery_IsCaseInsensitive() - { - var title = await SeedTrackAsync(Genre.Blues); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } - var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=blues&pageSize=100", TestContext.Current.CancellationToken); + [Fact] + public async Task GetTracks_GenreQuery_IsCaseInsensitive() + { + var title = await SeedTrackAsync(Genre.Blues); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); - Assert.Contains(body!.Items, item => item.Title == title); - } + var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=blues&pageSize=100", TestContext.Current.CancellationToken); - [Fact] - public async Task GetTracks_GenreQuery_FiltersByTag() - { - var title = await SeedTrackAsync(Genre.Ambient); - await SeedTrackAsync(Genre.Techno); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); + Assert.Contains(body!.Items, item => item.Title == title); + } - var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=Ambient&pageSize=100", TestContext.Current.CancellationToken); + [Fact] + public async Task GetTracks_GenreQuery_FiltersByTag() + { + var title = await SeedTrackAsync(Genre.Ambient); + await SeedTrackAsync(Genre.Techno); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); - Assert.Contains(body!.Items, item => item.Title == title); - Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Tags)); + var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=Ambient&pageSize=100", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); + Assert.Contains(body!.Items, item => item.Title == title); + Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Tags)); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs index 2067135..7d43fbf 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs @@ -8,93 +8,94 @@ using Musify.Domain.Entities; using Musify.Infrastructure.Persistence; using Xunit; -namespace Musify.Api.Tests.Endpoints; - -[Collection(ApiCollection.Name)] -public sealed class ListeningProgressEndpointsTests(ApiTestFixture fixture) +namespace Musify.Api.Tests.Endpoints { - private Task CreateUserAsync() => fixture.SeedUserAsync(); - - private async Task SeedListenAsync(long userId, double durationSeconds) + [Collection(ApiCollection.Name)] + public sealed class ListeningProgressEndpointsTests(ApiTestFixture fixture) { - using var scope = fixture.Services.CreateScope(); - var database = scope.ServiceProvider.GetRequiredService(); - var owner = await database.Users.SingleAsync(u => u.Id == userId, TestContext.Current.CancellationToken); - var track = new Track + private Task CreateUserAsync() => fixture.SeedUserAsync(); + + private async Task SeedListenAsync(long userId, double durationSeconds) { - Title = "Progress track", - NormalizedTitle = "PROGRESS TRACK", - OwnerUserId = owner.Id, - Owner = owner, - DurationSeconds = durationSeconds - }; - var listen = new ListeningHistory + using var scope = fixture.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + var owner = await database.Users.SingleAsync(u => u.Id == userId, TestContext.Current.CancellationToken); + var track = new Track + { + Title = "Progress track", + NormalizedTitle = "PROGRESS TRACK", + OwnerUserId = owner.Id, + Owner = owner, + DurationSeconds = durationSeconds + }; + var listen = new ListeningHistory + { + UserId = userId, + TrackId = track.Id, + ListenedAt = DateTime.UtcNow.AddMinutes(-10) + }; + database.Tracks.Add(track); + database.ListeningHistories.Add(listen); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + return listen.Id; + } + + private async Task LoadListenAsync(Guid listenId) { - UserId = userId, - TrackId = track.Id, - ListenedAt = DateTime.UtcNow.AddMinutes(-10) - }; - database.Tracks.Add(track); - database.ListeningHistories.Add(listen); - await database.SaveChangesAsync(TestContext.Current.CancellationToken); - return listen.Id; - } + using var scope = fixture.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + return await database.ListeningHistories.AsNoTracking() + .SingleAsync(l => l.Id == listenId, TestContext.Current.CancellationToken); + } - private async Task LoadListenAsync(Guid listenId) - { - using var scope = fixture.Services.CreateScope(); - var database = scope.ServiceProvider.GetRequiredService(); - return await database.ListeningHistories.AsNoTracking() - .SingleAsync(l => l.Id == listenId, TestContext.Current.CancellationToken); - } + [Fact] + public async Task PutProgress_Anonymous_ReturnsUnauthorized() + { + var response = await fixture.CreateAnonymousClient().PutAsJsonAsync( + $"/tracks/listens/{Guid.NewGuid()}/progress", new RecordListeningProgressRequest(10), TestContext.Current.CancellationToken); - [Fact] - public async Task PutProgress_Anonymous_ReturnsUnauthorized() - { - var response = await fixture.CreateAnonymousClient().PutAsJsonAsync( - $"/tracks/listens/{Guid.NewGuid()}/progress", new RecordListeningProgressRequest(10), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } + [Fact] + public async Task PutProgress_OwnListen_StoresSecondsAndMarksCounted() + { + var userId = await CreateUserAsync(); + var listenId = await SeedListenAsync(userId, durationSeconds: 200); - [Fact] - public async Task PutProgress_OwnListen_StoresSecondsAndMarksCounted() - { - var userId = await CreateUserAsync(); - var listenId = await SeedListenAsync(userId, durationSeconds: 200); + var response = await fixture.CreateAuthenticatedClient(userId).PutAsJsonAsync( + $"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(45), TestContext.Current.CancellationToken); - var response = await fixture.CreateAuthenticatedClient(userId).PutAsJsonAsync( - $"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(45), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + var stored = await LoadListenAsync(listenId); + Assert.Equal(45, stored.PlayedSeconds); + Assert.True(stored.IsCounted); + } - Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - var stored = await LoadListenAsync(listenId); - Assert.Equal(45, stored.PlayedSeconds); - Assert.True(stored.IsCounted); - } + [Fact] + public async Task PutProgress_OtherUsersListen_ReturnsNotFound() + { + var ownerId = await CreateUserAsync(); + var otherId = await CreateUserAsync(); + var listenId = await SeedListenAsync(ownerId, durationSeconds: 200); - [Fact] - public async Task PutProgress_OtherUsersListen_ReturnsNotFound() - { - var ownerId = await CreateUserAsync(); - var otherId = await CreateUserAsync(); - var listenId = await SeedListenAsync(ownerId, durationSeconds: 200); + var response = await fixture.CreateAuthenticatedClient(otherId).PutAsJsonAsync( + $"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(45), TestContext.Current.CancellationToken); - var response = await fixture.CreateAuthenticatedClient(otherId).PutAsJsonAsync( - $"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(45), TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Null((await LoadListenAsync(listenId)).PlayedSeconds); + } - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - Assert.Null((await LoadListenAsync(listenId)).PlayedSeconds); - } + [Fact] + public async Task PutProgress_NegativeSeconds_ReturnsBadRequest() + { + var userId = await CreateUserAsync(); + var listenId = await SeedListenAsync(userId, durationSeconds: 200); - [Fact] - public async Task PutProgress_NegativeSeconds_ReturnsBadRequest() - { - var userId = await CreateUserAsync(); - var listenId = await SeedListenAsync(userId, durationSeconds: 200); + var response = await fixture.CreateAuthenticatedClient(userId).PutAsJsonAsync( + $"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(-5), TestContext.Current.CancellationToken); - var response = await fixture.CreateAuthenticatedClient(userId).PutAsJsonAsync( - $"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(-5), TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs index faa9eda..d876e01 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/PlayListEndpointsTests.cs @@ -9,148 +9,149 @@ using Musify.Application.Shared; using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Api.Tests.Endpoints; - -[Collection(ApiCollection.Name)] -public sealed class PlayListEndpointsTests(ApiTestFixture fixture) +namespace Musify.Api.Tests.Endpoints { - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + [Collection(ApiCollection.Name)] + public sealed class PlayListEndpointsTests(ApiTestFixture fixture) { - Converters = { new JsonStringEnumConverter() } - }; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; - private static CancellationToken Ct => TestContext.Current.CancellationToken; + private static CancellationToken Ct => TestContext.Current.CancellationToken; - private async Task CreatePlayListAsync(long ownerId, PlayListVisibility visibility, string? name = null) - { - var response = await fixture.CreateAuthenticatedClient(ownerId).PostAsJsonAsync( - "/playlists", - new CreatePlayListRequest(name ?? $"pl-{Guid.NewGuid():N}"[..20], null, null, visibility), - Ct); - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - return (await response.Content.ReadFromJsonAsync(JsonOptions, Ct))!; - } + private async Task CreatePlayListAsync(long ownerId, PlayListVisibility visibility, string? name = null) + { + var response = await fixture.CreateAuthenticatedClient(ownerId).PostAsJsonAsync( + "/playlists", + new CreatePlayListRequest(name ?? $"pl-{Guid.NewGuid():N}"[..20], null, null, visibility), + Ct); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + return (await response.Content.ReadFromJsonAsync(JsonOptions, Ct))!; + } - [Fact] - public async Task GetPlayListById_PrivatePlayList_IsOnlyVisibleToItsOwner() - { - var owner = await fixture.SeedUserAsync(); - var stranger = await fixture.SeedUserAsync(); - var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private); + [Fact] + public async Task GetPlayListById_PrivatePlayList_IsOnlyVisibleToItsOwner() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private); - var asOwner = await fixture.CreateAuthenticatedClient(owner).GetAsync($"/playlists/{playList.Id}", Ct); - var asStranger = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/playlists/{playList.Id}", Ct); - var asAnonymous = await fixture.CreateAnonymousClient().GetAsync($"/playlists/{playList.Id}", Ct); + var asOwner = await fixture.CreateAuthenticatedClient(owner).GetAsync($"/playlists/{playList.Id}", Ct); + var asStranger = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/playlists/{playList.Id}", Ct); + var asAnonymous = await fixture.CreateAnonymousClient().GetAsync($"/playlists/{playList.Id}", Ct); - Assert.Equal(HttpStatusCode.OK, asOwner.StatusCode); - Assert.Equal(HttpStatusCode.NotFound, asStranger.StatusCode); - Assert.Equal(HttpStatusCode.NotFound, asAnonymous.StatusCode); - } + Assert.Equal(HttpStatusCode.OK, asOwner.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, asStranger.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, asAnonymous.StatusCode); + } - [Fact] - public async Task GetPlayListById_PublicPlayList_IsVisibleToAnonymousUsers() - { - var owner = await fixture.SeedUserAsync(); - var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); + [Fact] + public async Task GetPlayListById_PublicPlayList_IsVisibleToAnonymousUsers() + { + var owner = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); - var response = await fixture.CreateAnonymousClient().GetAsync($"/playlists/{playList.Id}", Ct); + var response = await fixture.CreateAnonymousClient().GetAsync($"/playlists/{playList.Id}", Ct); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } - [Fact] - public async Task GetPlayListTracks_PrivatePlayList_IsHiddenFromStrangers() - { - var owner = await fixture.SeedUserAsync(); - var stranger = await fixture.SeedUserAsync(); - var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private); + [Fact] + public async Task GetPlayListTracks_PrivatePlayList_IsHiddenFromStrangers() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private); - var asOwner = await fixture.CreateAuthenticatedClient(owner).GetAsync($"/playlists/{playList.Id}/tracks", Ct); - var asStranger = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/playlists/{playList.Id}/tracks", Ct); + var asOwner = await fixture.CreateAuthenticatedClient(owner).GetAsync($"/playlists/{playList.Id}/tracks", Ct); + var asStranger = await fixture.CreateAuthenticatedClient(stranger).GetAsync($"/playlists/{playList.Id}/tracks", Ct); - Assert.Equal(HttpStatusCode.OK, asOwner.StatusCode); - Assert.Equal(HttpStatusCode.NotFound, asStranger.StatusCode); - } + Assert.Equal(HttpStatusCode.OK, asOwner.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, asStranger.StatusCode); + } - [Fact] - public async Task GetPlayListsByUserId_StrangersOnlySeePublicPlayLists() - { - var owner = await fixture.SeedUserAsync(); - var stranger = await fixture.SeedUserAsync(); - var publicPlayList = await CreatePlayListAsync(owner, PlayListVisibility.Public); - var privatePlayList = await CreatePlayListAsync(owner, PlayListVisibility.Private); + [Fact] + public async Task GetPlayListsByUserId_StrangersOnlySeePublicPlayLists() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var publicPlayList = await CreatePlayListAsync(owner, PlayListVisibility.Public); + var privatePlayList = await CreatePlayListAsync(owner, PlayListVisibility.Private); - var asStranger = await ReadPlayListIdsAsync(fixture.CreateAuthenticatedClient(stranger), owner); - var asOwner = await ReadPlayListIdsAsync(fixture.CreateAuthenticatedClient(owner), owner); + var asStranger = await ReadPlayListIdsAsync(fixture.CreateAuthenticatedClient(stranger), owner); + var asOwner = await ReadPlayListIdsAsync(fixture.CreateAuthenticatedClient(owner), owner); - Assert.Equal([publicPlayList.Id], asStranger); - Assert.Contains(publicPlayList.Id, asOwner); - Assert.Contains(privatePlayList.Id, asOwner); - } + Assert.Equal([publicPlayList.Id], asStranger); + Assert.Contains(publicPlayList.Id, asOwner); + Assert.Contains(privatePlayList.Id, asOwner); + } - [Fact] - public async Task UpdatePlayList_NotTheOwner_ReturnsForbidden() - { - var owner = await fixture.SeedUserAsync(); - var stranger = await fixture.SeedUserAsync(); - var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); + [Fact] + public async Task UpdatePlayList_NotTheOwner_ReturnsForbidden() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); - var response = await fixture.CreateAuthenticatedClient(stranger).PutAsJsonAsync( - $"/playlists/{playList.Id}", new UpdatePlayListRequest("Hijacked", null, null), Ct); + var response = await fixture.CreateAuthenticatedClient(stranger).PutAsJsonAsync( + $"/playlists/{playList.Id}", new UpdatePlayListRequest("Hijacked", null, null), Ct); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - } + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } - [Fact] - public async Task DeletePlayList_NotTheOwner_ReturnsForbidden() - { - var owner = await fixture.SeedUserAsync(); - var stranger = await fixture.SeedUserAsync(); - var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); + [Fact] + public async Task DeletePlayList_NotTheOwner_ReturnsForbidden() + { + var owner = await fixture.SeedUserAsync(); + var stranger = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Public); - var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/playlists/{playList.Id}", Ct); + var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/playlists/{playList.Id}", Ct); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - } + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } - [Fact] - public async Task UpdatePlayList_Owner_RenamesIt() - { - var owner = await fixture.SeedUserAsync(); - var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private); + [Fact] + public async Task UpdatePlayList_Owner_RenamesIt() + { + var owner = await fixture.SeedUserAsync(); + var playList = await CreatePlayListAsync(owner, PlayListVisibility.Private); - var response = await fixture.CreateAuthenticatedClient(owner).PutAsJsonAsync( - $"/playlists/{playList.Id}", new UpdatePlayListRequest("Renamed", null, null), Ct); + var response = await fixture.CreateAuthenticatedClient(owner).PutAsJsonAsync( + $"/playlists/{playList.Id}", new UpdatePlayListRequest("Renamed", null, null), Ct); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync(JsonOptions, Ct); - Assert.Equal("Renamed", body!.Name); - } + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(JsonOptions, Ct); + Assert.Equal("Renamed", body!.Name); + } - [Fact] - public async Task GetPlayListById_NotAGuid_ReturnsNotFound() - { - var response = await fixture.CreateAnonymousClient().GetAsync("/playlists/not-a-guid", Ct); + [Fact] + public async Task GetPlayListById_NotAGuid_ReturnsNotFound() + { + var response = await fixture.CreateAnonymousClient().GetAsync("/playlists/not-a-guid", Ct); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } - [Fact] - public async Task CreatePlayList_NameTooLong_ReturnsValidationProblem() - { - var owner = await fixture.SeedUserAsync(); + [Fact] + public async Task CreatePlayList_NameTooLong_ReturnsValidationProblem() + { + var owner = await fixture.SeedUserAsync(); - var response = await fixture.CreateAuthenticatedClient(owner).PostAsJsonAsync( - "/playlists", new CreatePlayListRequest(new string('a', 51), null, null), Ct); + var response = await fixture.CreateAuthenticatedClient(owner).PostAsJsonAsync( + "/playlists", new CreatePlayListRequest(new string('a', 51), null, null), Ct); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } - private static async Task> ReadPlayListIdsAsync(HttpClient client, long userId) - { - var response = await client.GetAsync($"/playlists/users/{userId}", Ct); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var page = await response.Content.ReadFromJsonAsync>(JsonOptions, Ct); - return page!.Items.Select(playList => playList.Id).ToList(); + private static async Task> ReadPlayListIdsAsync(HttpClient client, long userId) + { + var response = await client.GetAsync($"/playlists/users/{userId}", Ct); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var page = await response.Content.ReadFromJsonAsync>(JsonOptions, Ct); + return page!.Items.Select(playList => playList.Id).ToList(); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs index 62dd90e..43498e2 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs @@ -11,131 +11,132 @@ using Musify.Domain.Entities; using Musify.Infrastructure.Persistence; using Xunit; -namespace Musify.Api.Tests.Endpoints; - -[Collection(ApiCollection.Name)] -public sealed class UserEndpointsTests(ApiTestFixture fixture) +namespace Musify.Api.Tests.Endpoints { - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + [Collection(ApiCollection.Name)] + public sealed class UserEndpointsTests(ApiTestFixture fixture) { - Converters = { new JsonStringEnumConverter() } - }; - - private async Task SeedListenAsync(long userId, string trackTitle, DateTime listenedAt) - { - using var scope = fixture.Services.CreateScope(); - var database = scope.ServiceProvider.GetRequiredService(); - var owner = await database.Users.SingleAsync(u => u.Id == userId); - var track = new Track + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { - Title = trackTitle, - NormalizedTitle = trackTitle.ToUpperInvariant(), - OwnerUserId = owner.Id, - Owner = owner + Converters = { new JsonStringEnumConverter() } }; - database.Tracks.Add(track); - database.ListeningHistories.Add(new ListeningHistory + + private async Task SeedListenAsync(long userId, string trackTitle, DateTime listenedAt) { - UserId = userId, - TrackId = track.Id, - ListenedAt = listenedAt, - IsCounted = true - }); - await database.SaveChangesAsync(TestContext.Current.CancellationToken); - } + using var scope = fixture.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + var owner = await database.Users.SingleAsync(u => u.Id == userId); + var track = new Track + { + Title = trackTitle, + NormalizedTitle = trackTitle.ToUpperInvariant(), + OwnerUserId = owner.Id, + Owner = owner + }; + database.Tracks.Add(track); + database.ListeningHistories.Add(new ListeningHistory + { + UserId = userId, + TrackId = track.Id, + ListenedAt = listenedAt, + IsCounted = true + }); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + } - [Fact] - public async Task PostUsers_IsNotExposed() - { - var client = fixture.CreateAnonymousClient(); + [Fact] + public async Task PostUsers_IsNotExposed() + { + var client = fixture.CreateAnonymousClient(); - var response = await client.PostAsJsonAsync("/users", new { id = 1, name = "Intruder" }, TestContext.Current.CancellationToken); + var response = await client.PostAsJsonAsync("/users", new { id = 1, name = "Intruder" }, TestContext.Current.CancellationToken); - Assert.True(response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed); - } + Assert.True(response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed); + } - [Fact] - public async Task GetUserById_ExistingUser_ReturnsIt() - { - var client = fixture.CreateAnonymousClient(); - var userId = Random.Shared.NextInt64(1, long.MaxValue); - await fixture.SeedUserAsync(userId, "Findable User"); + [Fact] + public async Task GetUserById_ExistingUser_ReturnsIt() + { + var client = fixture.CreateAnonymousClient(); + var userId = Random.Shared.NextInt64(1, long.MaxValue); + await fixture.SeedUserAsync(userId, "Findable User"); - 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); + } - [Fact] - public async Task GetLastTrackListenedByUserId_HasHistory_ReturnsMostRecentTrack() - { - var client = fixture.CreateAnonymousClient(); - var userId = Random.Shared.NextInt64(1, long.MaxValue); - await fixture.SeedUserAsync(userId); - await SeedListenAsync(userId, "Older listen", DateTime.UtcNow.AddMinutes(-10)); - await SeedListenAsync(userId, "Newer listen", DateTime.UtcNow); + [Fact] + public async Task GetLastTrackListenedByUserId_HasHistory_ReturnsMostRecentTrack() + { + var client = fixture.CreateAnonymousClient(); + var userId = Random.Shared.NextInt64(1, long.MaxValue); + await fixture.SeedUserAsync(userId); + await SeedListenAsync(userId, "Older listen", DateTime.UtcNow.AddMinutes(-10)); + await SeedListenAsync(userId, "Newer listen", DateTime.UtcNow); - var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken); + var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken); - Assert.Equal("Newer listen", body?.Title); - } + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken); + Assert.Equal("Newer listen", body?.Title); + } - [Fact] - public async Task GetLastTrackListenedByUserId_NoHistory_ReturnsNoContent() - { - var client = fixture.CreateAnonymousClient(); - var userId = Random.Shared.NextInt64(1, long.MaxValue); - await fixture.SeedUserAsync(userId); + [Fact] + public async Task GetLastTrackListenedByUserId_NoHistory_ReturnsNoContent() + { + var client = fixture.CreateAnonymousClient(); + var userId = Random.Shared.NextInt64(1, long.MaxValue); + await fixture.SeedUserAsync(userId); - var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken); + var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); - } + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + } - [Theory] - [InlineData("pageSize=0")] - [InlineData("pageSize=101")] - [InlineData("pageNumber=0")] - public async Task GetUsers_InvalidPaging_ReturnsBadRequest(string query) - { - var client = fixture.CreateAnonymousClient(); + [Theory] + [InlineData("pageSize=0")] + [InlineData("pageSize=101")] + [InlineData("pageNumber=0")] + public async Task GetUsers_InvalidPaging_ReturnsBadRequest(string query) + { + var client = fixture.CreateAnonymousClient(); - var response = await client.GetAsync($"/users?{query}", TestContext.Current.CancellationToken); + var response = await client.GetAsync($"/users?{query}", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } - [Fact] - public async Task GetListeningStats_AnotherUser_ReturnsForbidden() - { - var userId = await fixture.SeedUserAsync(); - var other = await fixture.SeedUserAsync(); - var client = fixture.CreateAuthenticatedClient(userId); + [Fact] + public async Task GetListeningStats_AnotherUser_ReturnsForbidden() + { + var userId = await fixture.SeedUserAsync(); + var other = await fixture.SeedUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); - var response = await client.GetAsync($"/users/{other}/listening-stats", TestContext.Current.CancellationToken); + var response = await client.GetAsync($"/users/{other}/listening-stats", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); - } + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } - [Fact] - public async Task GetListeningStats_Anonymous_ReturnsUnauthorized() - { - var response = await fixture.CreateAnonymousClient().GetAsync("/users/1/listening-stats", TestContext.Current.CancellationToken); + [Fact] + public async Task GetListeningStats_Anonymous_ReturnsUnauthorized() + { + var response = await fixture.CreateAnonymousClient().GetAsync("/users/1/listening-stats", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Extensions/ErrorOrHttpExtensionsTests.cs b/backend/Tests/Musify.Api.Tests/Extensions/ErrorOrHttpExtensionsTests.cs index 8b6320a..773c1ca 100644 --- a/backend/Tests/Musify.Api.Tests/Extensions/ErrorOrHttpExtensionsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Extensions/ErrorOrHttpExtensionsTests.cs @@ -4,132 +4,133 @@ using Microsoft.Extensions.DependencyInjection; using Musify.Api.Extensions; using Xunit; -namespace Musify.Api.Tests.Extensions; - -public sealed class ErrorOrHttpExtensionsTests +namespace Musify.Api.Tests.Extensions { - 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; + 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 18ecdcc..8b16cfe 100644 --- a/backend/Tests/Musify.Api.Tests/Filters/ValidationFilterTests.cs +++ b/backend/Tests/Musify.Api.Tests/Filters/ValidationFilterTests.cs @@ -6,56 +6,57 @@ 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); - - 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")); + 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 83751b4..5a3887b 100644 --- a/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs +++ b/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs @@ -12,108 +12,109 @@ using NSubstitute; using Testcontainers.PostgreSql; using Xunit; -namespace Musify.Api.Tests.TestSupport; - -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 IStreamTicketService StreamTicketService { get; } = Substitute.For(); - - public async ValueTask InitializeAsync() + 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(); - public override async ValueTask DisposeAsync() - { - await base.DisposeAsync(); - await postgres.DisposeAsync(); - } + public IEventBus EventBus { get; } = Substitute.For(); - public HttpClient CreateAnonymousClient() => CreateClient(); + public IStreamTicketService StreamTicketService { get; } = Substitute.For(); - public async Task SeedUserAsync(long? userId = null, string? name = null) - { - var id = userId ?? Random.Shared.NextInt64(1, long.MaxValue); - var userName = name ?? $"user-{id}"; - - using var scope = Services.CreateScope(); - var database = scope.ServiceProvider.GetRequiredService(); - database.Users.Add(new Musify.Domain.Entities.User + public async ValueTask InitializeAsync() { - Id = id, - Name = userName, - NormalizedName = userName.ToUpperInvariant() - }); - await database.SaveChangesAsync(); - return id; - } + await postgres.StartAsync(); - public HttpClient CreateAuthenticatedClient(long userId, string userName = "test-user") - { - var client = CreateClient(); - client.DefaultRequestHeaders.Add(FakeAuthenticationHandler.UserIdHeader, userId.ToString(CultureInfo.InvariantCulture)); - client.DefaultRequestHeaders.Add("X-Test-User-Name", userName); - return client; - } + using var scope = Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + await database.Database.MigrateAsync(); + } - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.ConfigureAppConfiguration((_, configuration) => + public override async ValueTask DisposeAsync() { - configuration.AddInMemoryCollection(new Dictionary + await base.DisposeAsync(); + await postgres.DisposeAsync(); + } + + public HttpClient CreateAnonymousClient() => CreateClient(); + + public async Task SeedUserAsync(long? userId = null, string? name = null) + { + var id = userId ?? Random.Shared.NextInt64(1, long.MaxValue); + var userName = name ?? $"user-{id}"; + + using var scope = Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + database.Users.Add(new Musify.Domain.Entities.User { - ["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", + Id = id, + Name = userName, + NormalizedName = userName.ToUpperInvariant() }); - }); + await database.SaveChangesAsync(); + return id; + } - builder.ConfigureTestServices(services => + public HttpClient CreateAuthenticatedClient(long userId, string userName = "test-user") { - services.Replace(ServiceDescriptor.Singleton(StorageService)); - services.Replace(ServiceDescriptor.Scoped(_ => EventBus)); - services.Replace(ServiceDescriptor.Singleton(StreamTicketService)); + var client = CreateClient(); + client.DefaultRequestHeaders.Add(FakeAuthenticationHandler.UserIdHeader, userId.ToString(CultureInfo.InvariantCulture)); + client.DefaultRequestHeaders.Add("X-Test-User-Name", userName); + return client; + } - services - .AddAuthentication(FakeAuthenticationHandler.SchemeName) - .AddScheme(FakeAuthenticationHandler.SchemeName, _ => { }); - }); + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureAppConfiguration((_, configuration) => + { + 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", + }); + }); + + builder.ConfigureTestServices(services => + { + services.Replace(ServiceDescriptor.Singleton(StorageService)); + services.Replace(ServiceDescriptor.Scoped(_ => EventBus)); + services.Replace(ServiceDescriptor.Singleton(StreamTicketService)); + + 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 c61ea8e..497bc7b 100644 --- a/backend/Tests/Musify.Api.Tests/TestSupport/FakeAuthenticationHandler.cs +++ b/backend/Tests/Musify.Api.Tests/TestSupport/FakeAuthenticationHandler.cs @@ -4,28 +4,29 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace Musify.Api.Tests.TestSupport; - -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() + 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 728c829..e591762 100644 --- a/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs +++ b/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs @@ -3,83 +3,84 @@ using Musify.Api.Validators; 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, Guid.NewGuid())); + 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, Guid.NewGuid())); - [Fact] - public void Validate_ValidRequestWithoutOptionalFields_Passes() - { - var result = validator.Validate(new CreateAlbumRequest("My Album", null, null, Guid.NewGuid())); + Assert.True(result.IsValid); + } - Assert.True(result.IsValid); - } + [Fact] + public void Validate_ValidRequestWithoutOptionalFields_Passes() + { + var result = validator.Validate(new CreateAlbumRequest("My Album", null, null, Guid.NewGuid())); - [Fact] - public void Validate_BlankTitle_Fails() - { - var result = validator.Validate(new CreateAlbumRequest("", null, null, Guid.NewGuid())); + 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, Guid.NewGuid())); - [Fact] - public void Validate_TitleOverMaxLength_Fails() - { - var result = validator.Validate(new CreateAlbumRequest(new string('a', 201), null, null, Guid.NewGuid())); + 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, Guid.NewGuid())); - [Fact] - public void Validate_ReleaseYearBeforeTheEarliestBoundary_Fails() - { - var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.EarliestReleaseYear - 1, Guid.NewGuid())); + Assert.False(result.IsValid); + } - Assert.False(result.IsValid); - } + [Fact] + public void Validate_ReleaseYearBeforeTheEarliestBoundary_Fails() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.EarliestReleaseYear - 1, Guid.NewGuid())); - [Fact] - public void Validate_ReleaseYearAtTheEarliestBoundary_Passes() - { - var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.EarliestReleaseYear, Guid.NewGuid())); + Assert.False(result.IsValid); + } - Assert.True(result.IsValid); - } + [Fact] + public void Validate_ReleaseYearAtTheEarliestBoundary_Passes() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.EarliestReleaseYear, Guid.NewGuid())); - [Fact] - public void Validate_ReleaseYearAtTheLatestBoundary_Passes() - { - var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.LatestReleaseYear, Guid.NewGuid())); + Assert.True(result.IsValid); + } - Assert.True(result.IsValid); - } + [Fact] + public void Validate_ReleaseYearAtTheLatestBoundary_Passes() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.LatestReleaseYear, Guid.NewGuid())); - [Fact] - public void Validate_ReleaseYearPastTheLatestBoundary_Fails() - { - var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.LatestReleaseYear + 1, Guid.NewGuid())); + Assert.True(result.IsValid); + } - Assert.False(result.IsValid); - } + [Fact] + public void Validate_ReleaseYearPastTheLatestBoundary_Fails() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, Limits.LatestReleaseYear + 1, Guid.NewGuid())); - [Fact] - public void Validate_EmptyPictureIntentId_Fails() - { - var result = validator.Validate(new CreateAlbumRequest("Title", null, null, Guid.Empty)); + Assert.False(result.IsValid); + } - Assert.False(result.IsValid); - Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.PictureIntentId)); + [Fact] + public void Validate_EmptyPictureIntentId_Fails() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, null, Guid.Empty)); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.PictureIntentId)); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Validators/TrackRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/TrackRequestValidatorTests.cs index 9643e18..59534b7 100644 --- a/backend/Tests/Musify.Api.Tests/Validators/TrackRequestValidatorTests.cs +++ b/backend/Tests/Musify.Api.Tests/Validators/TrackRequestValidatorTests.cs @@ -3,46 +3,47 @@ using Musify.Api.Validators.Tracks; using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Api.Tests.Validators; - -public sealed class TrackRequestValidatorTests +namespace Musify.Api.Tests.Validators { - [Fact] - public void CreateTrack_ValidRequest_Passes() + public sealed class TrackRequestValidatorTests { - var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Pop, Genre.Rock]); + [Fact] + public void CreateTrack_ValidRequest_Passes() + { + var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Pop, Genre.Rock]); - Assert.True(new CreateTrackRequestValidator().Validate(request).IsValid); - } + Assert.True(new CreateTrackRequestValidator().Validate(request).IsValid); + } - [Fact] - public void CreateTrack_NoTags_Fails() - { - var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), []); + [Fact] + public void CreateTrack_NoTags_Fails() + { + var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), []); - Assert.False(new CreateTrackRequestValidator().Validate(request).IsValid); - } + Assert.False(new CreateTrackRequestValidator().Validate(request).IsValid); + } - [Fact] - public void CreateTrack_IncompatibleTags_Fails() - { - var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Classical, Genre.Metal]); + [Fact] + public void CreateTrack_IncompatibleTags_Fails() + { + var request = new CreateTrackRequest("Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Classical, Genre.Metal]); - Assert.False(new CreateTrackRequestValidator().Validate(request).IsValid); - } + Assert.False(new CreateTrackRequestValidator().Validate(request).IsValid); + } - [Theory] - [InlineData(-1)] - [InlineData(double.NaN)] - [InlineData(double.PositiveInfinity)] - public void RecordListeningProgress_InvalidSeconds_Fails(double seconds) - { - Assert.False(new RecordListeningProgressRequestValidator().Validate(new RecordListeningProgressRequest(seconds)).IsValid); - } + [Theory] + [InlineData(-1)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void RecordListeningProgress_InvalidSeconds_Fails(double seconds) + { + Assert.False(new RecordListeningProgressRequestValidator().Validate(new RecordListeningProgressRequest(seconds)).IsValid); + } - [Fact] - public void RecordListeningProgress_ZeroSeconds_Passes() - { - Assert.True(new RecordListeningProgressRequestValidator().Validate(new RecordListeningProgressRequest(0)).IsValid); + [Fact] + public void RecordListeningProgress_ZeroSeconds_Passes() + { + Assert.True(new RecordListeningProgressRequestValidator().Validate(new RecordListeningProgressRequest(0)).IsValid); + } } } diff --git a/backend/Tests/Musify.Api.Tests/Validators/UploadRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/UploadRequestValidatorTests.cs index e9b5fa3..8a99892 100644 --- a/backend/Tests/Musify.Api.Tests/Validators/UploadRequestValidatorTests.cs +++ b/backend/Tests/Musify.Api.Tests/Validators/UploadRequestValidatorTests.cs @@ -5,45 +5,46 @@ using Musify.Api.Validators.Tracks; using Musify.Application.Configuration; using Xunit; -namespace Musify.Api.Tests.Validators; - -public sealed class UploadRequestValidatorTests +namespace Musify.Api.Tests.Validators { - private readonly UploadIntentConfiguration configuration = new() { MaxUploadBytes = 1_000 }; - - [Fact] - public void PictureUpload_ValidRequest_Passes() + public sealed class UploadRequestValidatorTests { - var validator = new PictureUploadRequestValidator(configuration); + private readonly UploadIntentConfiguration configuration = new() { MaxUploadBytes = 1_000 }; - var result = validator.Validate(new PictureUploadRequest("webp", "image/webp", 500)); + [Fact] + public void PictureUpload_ValidRequest_Passes() + { + var validator = new PictureUploadRequestValidator(configuration); - Assert.True(result.IsValid); - } + var result = validator.Validate(new PictureUploadRequest("webp", "image/webp", 500)); - [Theory] - [InlineData("exe", "image/webp", 500L)] - [InlineData("averyveryveryloongextension", "image/webp", 500L)] - [InlineData("we bp", "image/webp", 500L)] - [InlineData("webp", "text/html", 500L)] - [InlineData("webp", "image/webp", 0L)] - [InlineData("webp", "image/webp", 1_001L)] - public void PictureUpload_InvalidRequest_Fails(string fileType, string contentType, long size) - { - var validator = new PictureUploadRequestValidator(configuration); + Assert.True(result.IsValid); + } - var result = validator.Validate(new PictureUploadRequest(fileType, contentType, size)); + [Theory] + [InlineData("exe", "image/webp", 500L)] + [InlineData("averyveryveryloongextension", "image/webp", 500L)] + [InlineData("we bp", "image/webp", 500L)] + [InlineData("webp", "text/html", 500L)] + [InlineData("webp", "image/webp", 0L)] + [InlineData("webp", "image/webp", 1_001L)] + public void PictureUpload_InvalidRequest_Fails(string fileType, string contentType, long size) + { + var validator = new PictureUploadRequestValidator(configuration); - Assert.False(result.IsValid); - } + var result = validator.Validate(new PictureUploadRequest(fileType, contentType, size)); - [Fact] - public void TrackUpload_AudioTypesAreCheckedAgainstTheAudioAllowList() - { - var validator = new RequestTrackUploadUrlsRequestValidator(configuration); + Assert.False(result.IsValid); + } - Assert.True(validator.Validate(new RequestTrackUploadUrlsRequest("png", "image/png", "mp3", "audio/mpeg", 10, 900)).IsValid); - Assert.False(validator.Validate(new RequestTrackUploadUrlsRequest("png", "image/png", "png", "image/png")).IsValid); - Assert.False(validator.Validate(new RequestTrackUploadUrlsRequest("mp3", "audio/mpeg", "mp3", "audio/mpeg")).IsValid); + [Fact] + public void TrackUpload_AudioTypesAreCheckedAgainstTheAudioAllowList() + { + var validator = new RequestTrackUploadUrlsRequestValidator(configuration); + + Assert.True(validator.Validate(new RequestTrackUploadUrlsRequest("png", "image/png", "mp3", "audio/mpeg", 10, 900)).IsValid); + Assert.False(validator.Validate(new RequestTrackUploadUrlsRequest("png", "image/png", "png", "image/png")).IsValid); + Assert.False(validator.Validate(new RequestTrackUploadUrlsRequest("mp3", "audio/mpeg", "mp3", "audio/mpeg")).IsValid); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/AddTracksToAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/AddTracksToAlbumCommandHandlerTests.cs index 1619700..dacfaaf 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/AddTracksToAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/AddTracksToAlbumCommandHandlerTests.cs @@ -5,108 +5,109 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class AddTracksToAlbumCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private AddTracksToAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_FirstTrack_AddsItAtNumberOne() + public sealed class AddTracksToAlbumCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id); - var track = TestEntities.Track(owner); - await SeedAsync(owner, album, track); + private AddTracksToAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); - var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_FirstTrack_AddsItAtNumberOne() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + var track = TestEntities.Track(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 AddTracksToAlbumCommand(owner.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AlbumMissing_ReturnsNotFound() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(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 AddTracksToAlbumCommand(owner.Id, Guid.NewGuid(), [track.Id]), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, Guid.NewGuid(), [track.Id]), TestContext.Current.CancellationToken); - [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); - var track = TestEntities.Track(owner); - await SeedAsync(owner, stranger, album, track); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(stranger.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken); + [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); + var track = TestEntities.Track(owner); + await SeedAsync(owner, stranger, album, track); - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(stranger.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_TrackMissing_ReturnsNotFound() - { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id); - await SeedAsync(owner, album); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, album); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_TrackOwnedByAnotherUser_ReturnsForbidden() - { - var owner = TestEntities.User(1, "owner"); - var otherOwner = TestEntities.User(2, "other"); - var album = TestEntities.Album(owner.Id); - var foreignTrack = TestEntities.Track(otherOwner); - await SeedAsync(owner, otherOwner, album, foreignTrack); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [foreignTrack.Id]), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TrackOwnedByAnotherUser_ReturnsForbidden() + { + var owner = TestEntities.User(1, "owner"); + var otherOwner = TestEntities.User(2, "other"); + var album = TestEntities.Album(owner.Id); + var foreignTrack = TestEntities.Track(otherOwner); + await SeedAsync(owner, otherOwner, album, foreignTrack); - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [foreignTrack.Id]), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AlreadyInAlbum_IsSkipped() - { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id); - var track = TestEntities.Track(owner); - var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }; - await SeedAsync(owner, album, track, link); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_AlreadyInAlbum_IsSkipped() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + var track = TestEntities.Track(owner); + var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }; + await SeedAsync(owner, album, track, link); - Assert.False(result.IsError); - Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken)); - } + var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [track.Id]), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AlbumAlreadyHasTracks_AppendsAtNextNumber() - { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id); - var firstTrack = TestEntities.Track(owner, "First"); - var secondTrack = TestEntities.Track(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); + Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken)); + } - var result = await CreateHandler().Handle(new AddTracksToAlbumCommand(owner.Id, album.Id, [secondTrack.Id]), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_AlbumAlreadyHasTracks_AppendsAtNextNumber() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + var firstTrack = TestEntities.Track(owner, "First"); + var secondTrack = TestEntities.Track(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 AddTracksToAlbumCommand(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 5e5972c..fcf5d1d 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs @@ -6,69 +6,70 @@ using Musify.Application.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class CreateAlbumCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private readonly IEventBus eventBus = Substitute.For(); - private readonly IStorageService storageService = Substitute.For(); - - private CreateAlbumCommandHandler CreateHandler() => new( - eventBus, - Database, - new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), - NoOpLogger(), - TestConfigurations.Album()); - - [Fact] - public async Task Handle_ValidPictureIntent_CreatesAlbumAndPublishesResourcesEvent() + public sealed class CreateAlbumCommandHandlerTests : 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(); - var owner = TestEntities.User(); - var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "cover.webp"); - await SeedAsync(owner, intent); + private CreateAlbumCommandHandler CreateHandler() => new( + eventBus, + Database, + new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), + NoOpLogger(), + TestConfigurations.Album()); - var command = new CreateAlbumCommand(owner.Id, " My Album ", "A description", 2024, intent.Id); + [Fact] + public async Task Handle_ValidPictureIntent_CreatesAlbumAndPublishesResourcesEvent() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + var owner = TestEntities.User(); + var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "cover.webp"); + await SeedAsync(owner, intent); - 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 command = new CreateAlbumCommand(owner.Id, " My Album ", "A description", 2024, intent.Id); - var stored = await Database.Albums.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal("MY ALBUM", stored.NormalizedTitle); - Assert.Equal("cover.webp", stored.Pictures?.OriginalName); - await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); - } + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_UserDoesNotExist_ReturnsNotFound() - { - var command = new CreateAlbumCommand(404, "Orphan Album", null, null, Guid.NewGuid()); + 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.Albums.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("MY ALBUM", stored.NormalizedTitle); + 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_UserDoesNotExist_ReturnsNotFound() + { + var command = new CreateAlbumCommand(404, "Orphan Album", null, null, Guid.NewGuid()); - [Fact] - public async Task Handle_PictureIntentNotFound_ReturnsError() - { - var owner = TestEntities.User(); - await SeedAsync(owner); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var command = new CreateAlbumCommand(owner.Id, "My Album", null, 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 owner = TestEntities.User(); + await SeedAsync(owner); - Assert.True(result.IsError); + var command = new CreateAlbumCommand(owner.Id, "My Album", null, null, Guid.NewGuid()); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.True(result.IsError); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs index f6ab013..d7ac89c 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs @@ -6,49 +6,50 @@ using Musify.Domain.ValueObjects; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private readonly IEventBus eventBus = Substitute.For(); - - private DeleteAlbumCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); - - [Fact] - public async Task Handle_Owner_MarksAlbumAsRemoving() + public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id); - await SeedAsync(owner, album); + private readonly IEventBus eventBus = Substitute.For(); - var result = await CreateHandler().Handle(new DeleteAlbumCommand(owner.Id, album.Id), TestContext.Current.CancellationToken); + private DeleteAlbumCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); - Assert.False(result.IsError); - var stored = await Database.Albums.FindAsync([album.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_MarksAlbumAsRemoving() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, album); - [Fact] - public async Task Handle_AlbumMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new DeleteAlbumCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new DeleteAlbumCommand(owner.Id, album.Id), TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + Assert.False(result.IsError); + var stored = await Database.Albums.FindAsync([album.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_ReturnsForbiddenAndKeepsAlbum() - { - var owner = TestEntities.User(1, "owner"); - var stranger = TestEntities.User(2, "stranger"); - var album = TestEntities.Album(owner.Id); - await SeedAsync(owner, stranger, album); + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new DeleteAlbumCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new DeleteAlbumCommand(stranger.Id, album.Id), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - Assert.NotNull(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken)); + [Fact] + public async Task Handle_NotOwner_ReturnsForbiddenAndKeepsAlbum() + { + 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 DeleteAlbumCommand(stranger.Id, album.Id), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + Assert.NotNull(await Database.Albums.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 798e73f..bb21b98 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.Album(owner.Id); - var track = TestEntities.Track(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.Album(owner.Id); + var track = TestEntities.Track(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 26beb6c..955d7ab 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.Album(owner.Id); - var second = TestEntities.Track(owner, "Second"); - var first = TestEntities.Track(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.Album(owner.Id); + var second = TestEntities.Track(owner, "Second"); + var first = TestEntities.Track(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 244a269..93be0ed 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.Album(owner.Id, "Mine"), TestEntities.Album(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.Album(owner.Id, "Mine"), TestEntities.Album(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 b0df965..cda4557 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs @@ -2,42 +2,43 @@ 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.Album(owner.Id, "Alpha"), TestEntities.Album(owner.Id, "Beta")); + private GetAlbumsQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - Assert.Equal(2, result.TotalItemCount); - Assert.All(result.Items, item => Assert.NotNull(item)); - } + [Fact] + public async Task Handle_NoTitleFilter_ReturnsAllAlbumsPaged() + { + var owner = TestEntities.User(); + await SeedAsync(owner, TestEntities.Album(owner.Id, "Alpha"), TestEntities.Album(owner.Id, "Beta")); - [Fact] - public async Task Handle_TitleFilter_ReturnsMatchingAlbumsOnly() - { - var owner = TestEntities.User(); - await SeedAsync(owner, TestEntities.Album(owner.Id, "Rock Classics"), TestEntities.Album(owner.Id, "Jazz Favorites")); + var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + Assert.Equal(2, result.TotalItemCount); + Assert.All(result.Items, item => Assert.NotNull(item)); + } - var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - var item = Assert.Single(result.Items); - Assert.Equal("Rock Classics", item.Title); - } + [Fact] + public async Task Handle_TitleFilter_ReturnsMatchingAlbumsOnly() + { + var owner = TestEntities.User(); + await SeedAsync(owner, TestEntities.Album(owner.Id, "Rock Classics"), TestEntities.Album(owner.Id, "Jazz Favorites")); - [Fact] - public async Task Handle_PageSizeSmallerThanTotal_ReportsHasNextPage() - { - var owner = TestEntities.User(); - await SeedAsync(owner, TestEntities.Album(owner.Id, "One"), TestEntities.Album(owner.Id, "Two"), TestEntities.Album(owner.Id, "Three")); + var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + var item = Assert.Single(result.Items); + Assert.Equal("Rock Classics", item.Title); + } - var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 2), TestContext.Current.CancellationToken); - Assert.Equal(2, result.Items.Count); - Assert.True(result.HasNextPage); + [Fact] + public async Task Handle_PageSizeSmallerThanTotal_ReportsHasNextPage() + { + var owner = TestEntities.User(); + await SeedAsync(owner, TestEntities.Album(owner.Id, "One"), TestEntities.Album(owner.Id, "Two"), TestEntities.Album(owner.Id, "Three")); + + var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 2), TestContext.Current.CancellationToken); + Assert.Equal(2, result.Items.Count); + Assert.True(result.HasNextPage); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs index df36f5b..2cc2427 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs @@ -3,72 +3,73 @@ 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.Album(owner.Id, "Older"); - var newerAlbum = TestEntities.Album(owner.Id, "Newer"); - var olderTrack = TestEntities.Track(owner, "In older album"); - var newerTrack = TestEntities.Track(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); - Assert.Equal(["Newer", "Older"], result.Select(album => album.Title)); - } + [Fact] + public async Task Handle_ListenedTracks_ReturnsTheirAlbumsMostRecentFirst() + { + var owner = TestEntities.User(); + var olderAlbum = TestEntities.Album(owner.Id, "Older"); + var newerAlbum = TestEntities.Album(owner.Id, "Newer"); + var olderTrack = TestEntities.Track(owner, "In older album"); + var newerTrack = TestEntities.Track(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))); - [Fact] - public async Task Handle_NoListeningHistory_ReturnsEmptyList() - { - var owner = TestEntities.User(); - await SeedAsync(owner); + var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); + Assert.Equal(["Newer", "Older"], result.Select(album => album.Title)); + } - var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); - Assert.Empty(result); - } + [Fact] + public async Task Handle_NoListeningHistory_ReturnsEmptyList() + { + var owner = TestEntities.User(); + await SeedAsync(owner); - [Fact] - public async Task Handle_LimitLowerThanCandidates_ReturnsOnlyThatManyAlbums() - { - var owner = TestEntities.User(); - var albums = Enumerable.Range(1, 3).Select(i => TestEntities.Album(owner.Id, $"Album {i}")).ToList(); - var tracks = albums.Select((_, i) => TestEntities.Track(owner, $"Track {i}")).ToList(); - var links = albums.Zip(tracks, (album, track) => new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }).ToList(); - var histories = tracks.Select((track, i) => TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddMinutes(-i))).ToList(); - await SeedAsync([owner, .. albums, .. tracks, .. links, .. histories]); + var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); + Assert.Empty(result); + } - var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 2), TestContext.Current.CancellationToken); - Assert.Equal(2, result.Count); - } + [Fact] + public async Task Handle_LimitLowerThanCandidates_ReturnsOnlyThatManyAlbums() + { + var owner = TestEntities.User(); + var albums = Enumerable.Range(1, 3).Select(i => TestEntities.Album(owner.Id, $"Album {i}")).ToList(); + var tracks = albums.Select((_, i) => TestEntities.Track(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]); - [Fact] - public async Task Handle_UncountedListens_AreIgnored() - { - var owner = TestEntities.User(); - var countedAlbum = TestEntities.Album(owner.Id, "Counted album"); - var skippedAlbum = TestEntities.Album(owner.Id, "Skipped album"); - var countedTrack = TestEntities.Track(owner, "In counted"); - var skippedTrack = TestEntities.Track(owner, "In skipped"); - await SeedAsync( - owner, countedAlbum, skippedAlbum, countedTrack, skippedTrack, - new AlbumHasTrack { AlbumId = countedAlbum.Id, TrackId = countedTrack.Id, TrackNumber = 1 }, - new AlbumHasTrack { AlbumId = skippedAlbum.Id, TrackId = skippedTrack.Id, TrackNumber = 1 }, - TestEntities.ListeningHistory(owner.Id, countedTrack.Id, DateTime.UtcNow.AddDays(-1), playedSeconds: 120), - TestEntities.ListeningHistory(owner.Id, skippedTrack.Id, DateTime.UtcNow, playedSeconds: 1, isCounted: false)); + var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 2), TestContext.Current.CancellationToken); + Assert.Equal(2, result.Count); + } - var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_UncountedListens_AreIgnored() + { + var owner = TestEntities.User(); + var countedAlbum = TestEntities.Album(owner.Id, "Counted album"); + var skippedAlbum = TestEntities.Album(owner.Id, "Skipped album"); + var countedTrack = TestEntities.Track(owner, "In counted"); + var skippedTrack = TestEntities.Track(owner, "In skipped"); + await SeedAsync( + owner, countedAlbum, skippedAlbum, countedTrack, skippedTrack, + new AlbumHasTrack { AlbumId = countedAlbum.Id, TrackId = countedTrack.Id, TrackNumber = 1 }, + new AlbumHasTrack { AlbumId = skippedAlbum.Id, TrackId = skippedTrack.Id, TrackNumber = 1 }, + TestEntities.ListeningHistory(owner.Id, countedTrack.Id, DateTime.UtcNow.AddDays(-1), playedSeconds: 120), + TestEntities.ListeningHistory(owner.Id, skippedTrack.Id, DateTime.UtcNow, playedSeconds: 1, isCounted: false)); - Assert.Equal(["Counted album"], result.Select(album => album.Title)); + var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); + + Assert.Equal(["Counted album"], result.Select(album => album.Title)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs index 60be72f..f5e6a85 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.Album(owner.Id); - var first = TestEntities.Track(owner, "First"); - var second = TestEntities.Track(owner, "Second"); - var third = TestEntities.Track(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.Album(owner.Id); + var first = TestEntities.Track(owner, "First"); + var second = TestEntities.Track(owner, "Second"); + var third = TestEntities.Track(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_ReturnsForbidden() - { - var owner = TestEntities.User(1, "owner"); - var stranger = TestEntities.User(2, "stranger"); - var album = TestEntities.Album(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_ReturnsForbidden() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, stranger, album); - Assert.Equal(ErrorType.Forbidden, 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.Album(owner.Id); - await SeedAsync(owner, album); + Assert.Equal(ErrorType.Forbidden, 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.Album(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/RequestAlbumPictureUploadCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs index 98da07e..711218b 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs @@ -8,70 +8,71 @@ using Musify.Application.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class RequestAlbumPictureUploadCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private readonly IStorageService storageService = Substitute.For(); - - private RequestAlbumPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) + public sealed class RequestAlbumPictureUploadCommandHandlerTests : HandlerTestBase { - storageService - .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns("https://storage.musify.test/presigned-upload"); + private readonly IStorageService storageService = Substitute.For(); - var config = uploadIntentConfig ?? TestConfigurations.UploadIntent(); + private RequestAlbumPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) + { + storageService + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://storage.musify.test/presigned-upload"); - return new RequestAlbumPictureUploadCommandHandler( - new UploadIntentIssuer( - Database, - storageService, - new UploadIntentValidator(Database, storageService, config), - TestConfigurations.Storage(), - config, - NoOpLogger()), - TestConfigurations.Album()); - } + var config = uploadIntentConfig ?? TestConfigurations.UploadIntent(); - [Fact] - public async Task Handle_UserExists_IssuesUploadUrlAndCreatesIntent() - { - var user = TestEntities.User(); - await SeedAsync(user); + return new RequestAlbumPictureUploadCommandHandler( + new UploadIntentIssuer( + Database, + storageService, + new UploadIntentValidator(Database, storageService, config), + TestConfigurations.Storage(), + config, + NoOpLogger()), + TestConfigurations.Album()); + } - var command = new RequestAlbumPictureUploadCommand(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 RequestAlbumPictureUploadCommand(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 RequestAlbumPictureUploadCommand(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 RequestAlbumPictureUploadCommand(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 RequestAlbumPictureUploadCommand(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 RequestAlbumPictureUploadCommand(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/Albums/UpdateAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs index 41916a3..feb5986 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs @@ -6,86 +6,87 @@ using Musify.Application.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Albums; - -public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Albums { - private readonly IEventBus eventBus = Substitute.For(); - private readonly IStorageService storageService = Substitute.For(); - - private UpdateAlbumCommandHandler CreateHandler() => new( - eventBus, - Database, - new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), - NoOpLogger(), - TestConfigurations.Album()); - - [Fact] - public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear() + public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id, title: "Old Title", description: "Old", releaseYear: 2020); - await SeedAsync(owner, album); + private readonly IEventBus eventBus = Substitute.For(); + private readonly IStorageService storageService = Substitute.For(); - var command = new UpdateAlbumCommand(owner.Id, album.Id, " New Title ", "New description", 2025, null); + private UpdateAlbumCommandHandler CreateHandler() => new( + eventBus, + Database, + new UploadIntentValidator(Database, storageService, TestConfigurations.UploadIntent()), + NoOpLogger(), + TestConfigurations.Album()); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id, title: "Old Title", description: "Old", releaseYear: 2020); + await SeedAsync(owner, album); - 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 command = new UpdateAlbumCommand(owner.Id, album.Id, " New Title ", "New description", 2025, null); - var stored = await Database.Albums.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_ValidPictureIntent_UpdatesPictureAndPublishesSourceEvent() - { - storageService - .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ObjectMetaData("image/webp", 1024)); + 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 owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id, title: "Old Title"); - var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "new-cover.webp"); - await SeedAsync(owner, album, intent); + var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("NEW TITLE", stored.NormalizedTitle); + } - var command = new UpdateAlbumCommand(owner.Id, album.Id, "Old Title", null, null, intent.Id); + [Fact] + public async Task Handle_ValidPictureIntent_UpdatesPictureAndPublishesSourceEvent() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id, title: "Old Title"); + var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "new-cover.webp"); + await SeedAsync(owner, album, intent); - Assert.False(result.IsError); - var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName); - await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); - } + var command = new UpdateAlbumCommand(owner.Id, album.Id, "Old Title", null, null, intent.Id); - [Fact] - public async Task Handle_AlbumMissing_ReturnsNotFound() - { - var command = new UpdateAlbumCommand(1, Guid.NewGuid(), "Title", null, 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.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("new-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_AlbumMissing_ReturnsNotFound() + { + var command = new UpdateAlbumCommand(1, Guid.NewGuid(), "Title", null, null, null); - [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(command, TestContext.Current.CancellationToken); - var command = new UpdateAlbumCommand(stranger.Id, album.Id, "Hijacked", null, null, null); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [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); - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + var command = new UpdateAlbumCommand(stranger.Id, album.Id, "Hijacked", null, null, null); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Configuration/GetPlaybackPublicConfigTests.cs b/backend/Tests/Musify.Application.Tests/Configuration/GetPlaybackPublicConfigTests.cs index bc45a0b..fb11260 100644 --- a/backend/Tests/Musify.Application.Tests/Configuration/GetPlaybackPublicConfigTests.cs +++ b/backend/Tests/Musify.Application.Tests/Configuration/GetPlaybackPublicConfigTests.cs @@ -2,29 +2,30 @@ using Musify.Application.Configuration; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.Configuration; - -public sealed class GetPlaybackPublicConfigTests +namespace Musify.Application.Tests.Configuration { - [Fact] - public async Task Handle_ReflectsPlaybackConfiguration() + public sealed class GetPlaybackPublicConfigTests { - var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); - var handler = new GetPlaybackPublicConfigQueryHandler(playback); + [Fact] + public async Task Handle_ReflectsPlaybackConfiguration() + { + var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); + var handler = new GetPlaybackPublicConfigQueryHandler(playback); - var response = await handler.Handle(new GetPlaybackPublicConfigQuery(), TestContext.Current.CancellationToken); + var response = await handler.Handle(new GetPlaybackPublicConfigQuery(), TestContext.Current.CancellationToken); - Assert.True(response.AllowAnonymousListening); - Assert.Equal(30, response.AnonymousFragmentSeconds); - } + Assert.True(response.AllowAnonymousListening); + Assert.Equal(30, response.AnonymousFragmentSeconds); + } - [Fact] - public async Task Handle_DefaultsToDisabled() - { - var handler = new GetPlaybackPublicConfigQueryHandler(TestConfigurations.Playback()); + [Fact] + public async Task Handle_DefaultsToDisabled() + { + var handler = new GetPlaybackPublicConfigQueryHandler(TestConfigurations.Playback()); - var response = await handler.Handle(new GetPlaybackPublicConfigQuery(), TestContext.Current.CancellationToken); + var response = await handler.Handle(new GetPlaybackPublicConfigQuery(), TestContext.Current.CancellationToken); - Assert.False(response.AllowAnonymousListening); + Assert.False(response.AllowAnonymousListening); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Configuration/PicturesConfigurationTests.cs b/backend/Tests/Musify.Application.Tests/Configuration/PicturesConfigurationTests.cs index d2d0603..2c34e5f 100644 --- a/backend/Tests/Musify.Application.Tests/Configuration/PicturesConfigurationTests.cs +++ b/backend/Tests/Musify.Application.Tests/Configuration/PicturesConfigurationTests.cs @@ -1,50 +1,51 @@ using Musify.Application.Configuration; using Xunit; -namespace Musify.Application.Tests.Configuration; - -public sealed class PicturesConfigurationTests +namespace Musify.Application.Tests.Configuration { - [Fact] - public void AlbumRoutes_BuildTheSameKeysAsBefore() + public sealed class PicturesConfigurationTests { - var routes = new AlbumConfiguration().Routes; + [Fact] + public void AlbumRoutes_BuildTheSameKeysAsBefore() + { + var routes = new AlbumConfiguration().Routes; - Assert.Equal("Albums/SmallPictures/a.webp", routes.BuildPicturePath(PictureSize.Small, "a.webp")); - Assert.Equal("Albums/MediumPictures/a.webp", routes.BuildPicturePath(PictureSize.Medium, "a.webp")); - Assert.Equal("Albums/LargePictures/a.webp", routes.BuildPicturePath(PictureSize.Large, "a.webp")); - Assert.Equal("uploads/7/Albums/OriginalPictures/a.webp", routes.BuildOriginalPicturePath(7, "a.webp")); - Assert.Equal("temp/7/Albums/obj.webp", routes.BuildTempPath("temp", 7, "obj.webp")); + Assert.Equal("Albums/SmallPictures/a.webp", routes.BuildPicturePath(PictureSize.Small, "a.webp")); + Assert.Equal("Albums/MediumPictures/a.webp", routes.BuildPicturePath(PictureSize.Medium, "a.webp")); + Assert.Equal("Albums/LargePictures/a.webp", routes.BuildPicturePath(PictureSize.Large, "a.webp")); + Assert.Equal("uploads/7/Albums/OriginalPictures/a.webp", routes.BuildOriginalPicturePath(7, "a.webp")); + Assert.Equal("temp/7/Albums/obj.webp", routes.BuildTempPath("temp", 7, "obj.webp")); + } + + [Fact] + public void TrackRoutes_KeepTheTrackFolderLayout() + { + var routes = new TrackConfiguration().Routes; + + Assert.Equal("Tracks/ProcessedAudios/folder", routes.BuildProcessedAudioPath("folder")); + Assert.Equal("uploads/7/Tracks/OriginalAudios/song.mp3", routes.BuildOriginalAudioPath(7, "song.mp3")); + Assert.Equal("Tracks/PresetSmallPicture.webp", routes.PresetSmallPicturePath); + } + + [Fact] + public void ToImageSizes_UsesTheRoutesFolders() + { + var configuration = new PlayListConfiguration(); + + var sizes = configuration.PicturesSizes.ToImageSizes(configuration.Routes); + + Assert.Equal("PlayLists/SmallPictures", sizes.Small.SavePath); + Assert.Equal(128, sizes.Small.Width); + Assert.Equal(512, sizes.Large.Width); + } + + [Theory] + [InlineData("small", PictureSize.Small)] + [InlineData("LARGE", PictureSize.Large)] + [InlineData("medium", PictureSize.Medium)] + [InlineData("whatever", PictureSize.Medium)] + [InlineData(null, PictureSize.Medium)] + public void PictureSizeParser_DefaultsToMedium(string? value, PictureSize expected) => + Assert.Equal(expected, PictureSizeParser.Parse(value)); } - - [Fact] - public void TrackRoutes_KeepTheTrackFolderLayout() - { - var routes = new TrackConfiguration().Routes; - - Assert.Equal("Tracks/ProcessedAudios/folder", routes.BuildProcessedAudioPath("folder")); - Assert.Equal("uploads/7/Tracks/OriginalAudios/song.mp3", routes.BuildOriginalAudioPath(7, "song.mp3")); - Assert.Equal("Tracks/PresetSmallPicture.webp", routes.PresetSmallPicturePath); - } - - [Fact] - public void ToImageSizes_UsesTheRoutesFolders() - { - var configuration = new PlayListConfiguration(); - - var sizes = configuration.PicturesSizes.ToImageSizes(configuration.Routes); - - Assert.Equal("PlayLists/SmallPictures", sizes.Small.SavePath); - Assert.Equal(128, sizes.Small.Width); - Assert.Equal(512, sizes.Large.Width); - } - - [Theory] - [InlineData("small", PictureSize.Small)] - [InlineData("LARGE", PictureSize.Large)] - [InlineData("medium", PictureSize.Medium)] - [InlineData("whatever", PictureSize.Medium)] - [InlineData(null, PictureSize.Medium)] - public void PictureSizeParser_DefaultsToMedium(string? value, PictureSize expected) => - Assert.Equal(expected, PictureSizeParser.Parse(value)); } diff --git a/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs index 9e63b58..3b1473c 100644 --- a/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs @@ -3,30 +3,31 @@ using Musify.Application.Genres.Responses; using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Application.Tests.Genres; - -public sealed class GetAvailableGenresQueryHandlerTests +namespace Musify.Application.Tests.Genres { - private static async Task> RunAsync() => - await new GetAvailableGenresQueryHandler().Handle(new GetAvailableGenresQuery(), TestContext.Current.CancellationToken); - - [Fact] - public async Task Handle_ReturnsEveryGenre() + public sealed class GetAvailableGenresQueryHandlerTests { - var result = await RunAsync(); + private static async Task> RunAsync() => + await new GetAvailableGenresQueryHandler().Handle(new GetAvailableGenresQuery(), TestContext.Current.CancellationToken); - Assert.Equal(Enum.GetValues(), result.Select(r => r.Genre)); - } + [Fact] + public async Task Handle_ReturnsEveryGenre() + { + var result = await RunAsync(); - [Fact] - public async Task Handle_ListsIncompatibleGenresSymmetrically() - { - var result = await RunAsync(); + Assert.Equal(Enum.GetValues(), result.Select(r => r.Genre)); + } - var metal = result.Single(r => r.Genre == Genre.Metal); - var lofi = result.Single(r => r.Genre == Genre.Lofi); - Assert.Contains(Genre.Lofi, metal.IncompatibleWith); - Assert.Contains(Genre.Metal, lofi.IncompatibleWith); - Assert.Empty(result.Single(r => r.Genre == Genre.Pop).IncompatibleWith); + [Fact] + public async Task Handle_ListsIncompatibleGenresSymmetrically() + { + var result = await RunAsync(); + + var metal = result.Single(r => r.Genre == Genre.Metal); + var lofi = result.Single(r => r.Genre == Genre.Lofi); + Assert.Contains(Genre.Lofi, metal.IncompatibleWith); + Assert.Contains(Genre.Metal, lofi.IncompatibleWith); + Assert.Empty(result.Single(r => r.Genre == Genre.Pop).IncompatibleWith); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs index cb76ee5..4086ba1 100644 --- a/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs @@ -3,35 +3,36 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Application.Tests.Genres; - -public sealed class GetGenresQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Genres { - private GetGenresQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_NoTracks_ReturnsEmpty() + public sealed class GetGenresQueryHandlerTests : HandlerTestBase { - var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); + private GetGenresQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_NoTracks_ReturnsEmpty() + { + var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); + + Assert.Empty(result); + } + + [Fact] + public async Task Handle_ReturnsOnlyGenresInUse_MostPopulatedFirst() + { + var owner = TestEntities.User(); + var rockA = TestEntities.Track(owner, "Rock A", tags: [Genre.Rock]); + var rockB = TestEntities.Track(owner, "Rock B", tags: [Genre.Rock, Genre.Indie]); + var jazz = TestEntities.Track(owner, "Jazz", tags: [Genre.Jazz]); + await SeedAsync( + owner, rockA, rockB, jazz); + + var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); + + Assert.Equal( + [(Genre.Rock, 2), (Genre.Jazz, 1), (Genre.Indie, 1)], + result.Select(r => (r.Genre, r.TrackCount))); + } - Assert.Empty(result); } - - [Fact] - public async Task Handle_ReturnsOnlyGenresInUse_MostPopulatedFirst() - { - var owner = TestEntities.User(); - var rockA = TestEntities.Track(owner, "Rock A", tags: [Genre.Rock]); - var rockB = TestEntities.Track(owner, "Rock B", tags: [Genre.Rock, Genre.Indie]); - var jazz = TestEntities.Track(owner, "Jazz", tags: [Genre.Jazz]); - await SeedAsync( - owner, rockA, rockB, jazz); - - var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); - - Assert.Equal( - [(Genre.Rock, 2), (Genre.Jazz, 1), (Genre.Indie, 1)], - result.Select(r => (r.Genre, r.TrackCount))); - } - } diff --git a/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs index 9234f5b..30f1782 100644 --- a/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs @@ -3,46 +3,47 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Likes; - -public sealed class GetLikedTracksQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Likes { - private GetLikedTracksQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_MultipleLikes_ReturnsMostRecentFirst() + public sealed class GetLikedTracksQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var older = TestEntities.Track(owner, "Older like"); - var newer = TestEntities.Track(owner, "Newer like"); - await SeedAsync( - owner, older, newer, - new TrackLike { UserId = owner.Id, TrackId = older.Id, CreatedAt = DateTime.UtcNow.AddMinutes(-10) }, - new TrackLike { UserId = owner.Id, TrackId = newer.Id, CreatedAt = DateTime.UtcNow }); + private GetLikedTracksQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); - Assert.Equal(["Newer like", "Older like"], result.Items.Select(track => track.Title)); - } + [Fact] + public async Task Handle_MultipleLikes_ReturnsMostRecentFirst() + { + var owner = TestEntities.User(); + var older = TestEntities.Track(owner, "Older like"); + var newer = TestEntities.Track(owner, "Newer like"); + await SeedAsync( + owner, older, newer, + new TrackLike { UserId = owner.Id, TrackId = older.Id, CreatedAt = DateTime.UtcNow.AddMinutes(-10) }, + new TrackLike { UserId = owner.Id, TrackId = newer.Id, CreatedAt = DateTime.UtcNow }); - [Fact] - public async Task Handle_NoLikes_ReturnsEmpty() - { - var owner = TestEntities.User(); - await SeedAsync(owner); + var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); + Assert.Equal(["Newer like", "Older like"], result.Items.Select(track => track.Title)); + } - var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); - Assert.Empty(result.Items); - } + [Fact] + public async Task Handle_NoLikes_ReturnsEmpty() + { + var owner = TestEntities.User(); + await SeedAsync(owner); - [Fact] - public async Task Handle_OtherUsersLikes_AreExcluded() - { - var owner = TestEntities.User(1, "owner"); - var stranger = TestEntities.User(2, "stranger"); - var track = TestEntities.Track(owner); - await SeedAsync(owner, stranger, track, new TrackLike { UserId = stranger.Id, TrackId = track.Id }); + var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); + Assert.Empty(result.Items); + } - var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); - Assert.Empty(result.Items); + [Fact] + public async Task Handle_OtherUsersLikes_AreExcluded() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var track = TestEntities.Track(owner); + await SeedAsync(owner, stranger, track, new TrackLike { UserId = stranger.Id, TrackId = track.Id }); + + var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); + Assert.Empty(result.Items); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs index 1234a57..f03b174 100644 --- a/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs @@ -4,52 +4,53 @@ using Musify.Application.Likes; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.Likes; - -public sealed class ToggleTrackLikeCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Likes { - private ToggleTrackLikeCommandHandler CreateHandler() => - new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_TrackNotLiked_LikesIt() + public sealed class ToggleTrackLikeCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + private ToggleTrackLikeCommandHandler CreateHandler() => + new(Database, NoOpLogger()); - var result = await CreateHandler().Handle( - new ToggleTrackLikeCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TrackNotLiked_LikesIt() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); - Assert.False(result.IsError); - Assert.True(result.Value); - Assert.Single(await Database.TrackLikes.ToListAsync(TestContext.Current.CancellationToken)); - } + var result = await CreateHandler().Handle( + new ToggleTrackLikeCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_TrackAlreadyLiked_UnlikesIt() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track, new Musify.Domain.Entities.TrackLike { UserId = owner.Id, TrackId = track.Id }); + Assert.False(result.IsError); + Assert.True(result.Value); + Assert.Single(await Database.TrackLikes.ToListAsync(TestContext.Current.CancellationToken)); + } - var result = await CreateHandler().Handle( - new ToggleTrackLikeCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TrackAlreadyLiked_UnlikesIt() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track, new Musify.Domain.Entities.TrackLike { UserId = owner.Id, TrackId = track.Id }); - Assert.False(result.IsError); - Assert.False(result.Value); - Assert.Empty(await Database.TrackLikes.ToListAsync(TestContext.Current.CancellationToken)); - } + var result = await CreateHandler().Handle( + new ToggleTrackLikeCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_TrackMissing_ReturnsNotFound() - { - var owner = TestEntities.User(); - await SeedAsync(owner); + Assert.False(result.IsError); + Assert.False(result.Value); + Assert.Empty(await Database.TrackLikes.ToListAsync(TestContext.Current.CancellationToken)); + } - var result = await CreateHandler().Handle( - new ToggleTrackLikeCommand(owner.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var owner = TestEntities.User(); + await SeedAsync(owner); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + var result = await CreateHandler().Handle( + new ToggleTrackLikeCommand(owner.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs index 0a30e30..47ee9e6 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs @@ -4,78 +4,79 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Application.Tests.Mixes; - -public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Mixes { - private GenerateMixesForUserCommandHandler CreateHandler() => new( - Database, TestConfigurations.Mix(), NoOpLogger()); - - [Fact] - public async Task Handle_NoTracks_DoesNotCreateAnyMix() + public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase { - var user = TestEntities.User(); - await SeedAsync(user); + private GenerateMixesForUserCommandHandler CreateHandler() => new( + Database, TestConfigurations.Mix(), NoOpLogger()); - await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); - Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken)); - } + [Fact] + public async Task Handle_NoTracks_DoesNotCreateAnyMix() + { + var user = TestEntities.User(); + await SeedAsync(user); - [Fact] - public async Task Handle_AllTracksAlreadyListenedTo_CreatesOnlyTheDailyMix() - { - var user = TestEntities.User(); - var track = TestEntities.Track(user); - await SeedAsync( - user, track, - TestEntities.ListeningHistory(user.Id, track.Id)); + await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); + Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken)); + } - await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); - var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); - Assert.Single(mixes); - Assert.Equal(MixKind.Daily, mixes[0].Kind); - } + [Fact] + public async Task Handle_AllTracksAlreadyListenedTo_CreatesOnlyTheDailyMix() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id)); - [Fact] - public async Task Handle_UnheardTracksInLibrary_CreatesDiscoveryAndDailyMixes_AndReplacesPreviousMixes() - { - var user = TestEntities.User(); - var listenedTrack = TestEntities.Track(user, "Listened Track"); - var unheardTrack = TestEntities.Track(user, "Unheard Track"); + await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); + var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); + Assert.Single(mixes); + Assert.Equal(MixKind.Daily, mixes[0].Kind); + } - var staleMix = TestEntities.Mix(user.Id, MixKind.Daily); - var staleItem = TestEntities.MixItem(staleMix.Id, listenedTrack.Id); + [Fact] + public async Task Handle_UnheardTracksInLibrary_CreatesDiscoveryAndDailyMixes_AndReplacesPreviousMixes() + { + var user = TestEntities.User(); + var listenedTrack = TestEntities.Track(user, "Listened Track"); + var unheardTrack = TestEntities.Track(user, "Unheard Track"); - await SeedAsync( - user, listenedTrack, unheardTrack, staleMix, staleItem, - TestEntities.ListeningHistory(user.Id, listenedTrack.Id)); + var staleMix = TestEntities.Mix(user.Id, MixKind.Daily); + var staleItem = TestEntities.MixItem(staleMix.Id, listenedTrack.Id); - await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); - var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); - Assert.DoesNotContain(mixes, mix => mix.Id == staleMix.Id); - Assert.Contains(mixes, mix => mix.Kind == MixKind.Discovery); - Assert.Contains(mixes, mix => mix.Kind == MixKind.Daily); - Assert.Empty(await Database.MixItems.Where(item => item.MixId == staleMix.Id).ToListAsync(TestContext.Current.CancellationToken)); + await SeedAsync( + user, listenedTrack, unheardTrack, staleMix, staleItem, + TestEntities.ListeningHistory(user.Id, listenedTrack.Id)); - var discoveryMix = mixes.Single(mix => mix.Kind == MixKind.Discovery); - var discoveryItems = await Database.MixItems - .Where(item => item.MixId == discoveryMix.Id) - .ToListAsync(TestContext.Current.CancellationToken); - Assert.All(discoveryItems, item => Assert.Equal(unheardTrack.Id, item.TrackId)); - } + await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); + var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); + Assert.DoesNotContain(mixes, mix => mix.Id == staleMix.Id); + Assert.Contains(mixes, mix => mix.Kind == MixKind.Discovery); + Assert.Contains(mixes, mix => mix.Kind == MixKind.Daily); + Assert.Empty(await Database.MixItems.Where(item => item.MixId == staleMix.Id).ToListAsync(TestContext.Current.CancellationToken)); - [Fact] - public async Task Handle_TrackWithOnlyUncountedListens_IsTreatedAsUnheard() - { - var user = TestEntities.User(); - var skipped = TestEntities.Track(user, "Only skipped"); - await SeedAsync( - user, skipped, - TestEntities.ListeningHistory(user.Id, skipped.Id, playedSeconds: 3, isCounted: false)); + var discoveryMix = mixes.Single(mix => mix.Kind == MixKind.Discovery); + var discoveryItems = await Database.MixItems + .Where(item => item.MixId == discoveryMix.Id) + .ToListAsync(TestContext.Current.CancellationToken); + Assert.All(discoveryItems, item => Assert.Equal(unheardTrack.Id, item.TrackId)); + } - await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TrackWithOnlyUncountedListens_IsTreatedAsUnheard() + { + var user = TestEntities.User(); + var skipped = TestEntities.Track(user, "Only skipped"); + await SeedAsync( + user, skipped, + TestEntities.ListeningHistory(user.Id, skipped.Id, playedSeconds: 3, isCounted: false)); - var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); - Assert.Contains(mixes, mix => mix.Kind == MixKind.Discovery); + await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); + + var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); + Assert.Contains(mixes, mix => mix.Kind == MixKind.Discovery); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs index 25274c3..2d456d8 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs @@ -4,47 +4,48 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.ValueObjects; 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 firstTrack = TestEntities.Track(owner, "First"); - var secondTrack = TestEntities.Track(owner, "Second"); - var mix = TestEntities.Mix(owner.Id, MixKind.Daily); - var second = TestEntities.MixItem(mix.Id, secondTrack.Id, position: 1); - var first = TestEntities.MixItem(mix.Id, firstTrack.Id, position: 0); - await SeedAsync(owner, firstTrack, secondTrack, 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 firstTrack = TestEntities.Track(owner, "First"); + var secondTrack = TestEntities.Track(owner, "Second"); + var mix = TestEntities.Mix(owner.Id, MixKind.Daily); + var second = TestEntities.MixItem(mix.Id, secondTrack.Id, position: 1); + var first = TestEntities.MixItem(mix.Id, firstTrack.Id, position: 0); + await SeedAsync(owner, firstTrack, secondTrack, mix, second, first); - Assert.False(result.IsError); - Assert.Equal(MixKind.Daily, result.Value.Kind); - 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(MixKind.Daily, result.Value.Kind); + 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 f46344f..baa8c34 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs @@ -3,33 +3,34 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.ValueObjects; 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, MixKind.Daily, position: 1); - var first = TestEntities.Mix(owner.Id, MixKind.Discovery, position: 0); - var theirs = TestEntities.Mix(other.Id); - await SeedAsync(owner, other, second, first, theirs); + private GetMixesByUserIdQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); - Assert.Equal([MixKind.Discovery, MixKind.Daily], result.Select(mix => mix.Kind)); - } + [Fact] + public async Task Handle_ReturnsOnlyThatUsersMixesOrderedByPosition() + { + var owner = TestEntities.User(1, "owner"); + var other = TestEntities.User(2, "other"); + var second = TestEntities.Mix(owner.Id, MixKind.Daily, position: 1); + var first = TestEntities.Mix(owner.Id, MixKind.Discovery, position: 0); + var theirs = TestEntities.Mix(other.Id); + await SeedAsync(owner, other, second, first, theirs); - [Fact] - public async Task Handle_NoMixes_ReturnsEmptyList() - { - var owner = TestEntities.User(); - await SeedAsync(owner); + var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); + Assert.Equal([MixKind.Discovery, MixKind.Daily], result.Select(mix => mix.Kind)); + } - var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); - Assert.Empty(result); + [Fact] + public async Task Handle_NoMixes_ReturnsEmptyList() + { + var owner = TestEntities.User(); + await SeedAsync(owner); + + var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); + Assert.Empty(result); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Pictures/GetCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Pictures/GetCoverQueryHandlerTests.cs index 63a3c8f..386627f 100644 --- a/backend/Tests/Musify.Application.Tests/Pictures/GetCoverQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Pictures/GetCoverQueryHandlerTests.cs @@ -5,100 +5,101 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Application.Tests.Pictures; - -public sealed class GetCoverQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Pictures { - private GetCoverQueryHandler CreateHandler() => new( - Database, - TestConfigurations.Storage("covers-bucket"), - TestConfigurations.Track(), - TestConfigurations.Album(), - TestConfigurations.PlayList()); - - [Theory] - [InlineData(PictureSize.Small, "small.webp")] - [InlineData(PictureSize.Medium, "medium.webp")] - [InlineData(PictureSize.Large, "large.webp")] - public async Task Handle_ProcessedTrack_ReturnsRequestedSize(PictureSize size, string expectedFileName) + public sealed class GetCoverQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + private GetCoverQueryHandler CreateHandler() => new( + Database, + TestConfigurations.Storage("covers-bucket"), + TestConfigurations.Track(), + TestConfigurations.Album(), + TestConfigurations.PlayList()); - var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Track, track.Id, size), TestContext.Current.CancellationToken); + [Theory] + [InlineData(PictureSize.Small, "small.webp")] + [InlineData(PictureSize.Medium, "medium.webp")] + [InlineData(PictureSize.Large, "large.webp")] + public async Task Handle_ProcessedTrack_ReturnsRequestedSize(PictureSize size, string expectedFileName) + { + var owner = TestEntities.User(); + var track = TestEntities.Track(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 GetCoverQuery(CoverOwner.Track, track.Id, size), TestContext.Current.CancellationToken); - [Theory] - [InlineData(PictureSize.Small, "small.webp")] - [InlineData(PictureSize.Medium, "medium.webp")] - [InlineData(PictureSize.Large, "large.webp")] - public async Task Handle_ProcessedAlbum_ReturnsRequestedSize(PictureSize size, string expectedFileName) - { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id); - await SeedAsync(owner, album); + 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 GetCoverQuery(CoverOwner.Album, album.Id, size), TestContext.Current.CancellationToken); + [Theory] + [InlineData(PictureSize.Small, "small.webp")] + [InlineData(PictureSize.Medium, "medium.webp")] + [InlineData(PictureSize.Large, "large.webp")] + public async Task Handle_ProcessedAlbum_ReturnsRequestedSize(PictureSize size, string expectedFileName) + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, album); - Assert.False(result.IsError); - Assert.EndsWith(expectedFileName, result.Value.Key); - } + var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id, size), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_ProcessedPlayList_ReturnsRequestedSize() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, playList); + Assert.False(result.IsError); + Assert.EndsWith(expectedFileName, result.Value.Key); + } - var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.PlayList, playList.Id, PictureSize.Large), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ProcessedPlayList_ReturnsRequestedSize() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, playList); - Assert.False(result.IsError); - Assert.EndsWith("large.webp", result.Value.Key); - } + var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.PlayList, playList.Id, PictureSize.Large), TestContext.Current.CancellationToken); - [Theory] - [InlineData(CoverOwner.Track)] - [InlineData(CoverOwner.Album)] - [InlineData(CoverOwner.PlayList)] - public async Task Handle_EntityMissing_ReturnsNotFound(CoverOwner owner) - { - var result = await CreateHandler().Handle(new GetCoverQuery(owner, Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.EndsWith("large.webp", result.Value.Key); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Theory] + [InlineData(CoverOwner.Track)] + [InlineData(CoverOwner.Album)] + [InlineData(CoverOwner.PlayList)] + public async Task Handle_EntityMissing_ReturnsNotFound(CoverOwner owner) + { + var result = await CreateHandler().Handle(new GetCoverQuery(owner, Guid.NewGuid()), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AlbumPicturesStillProcessing_FallsBackToTheOriginal() - { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id, pictures: EntityPictures.Pending("original.png")); - await SeedAsync(owner, album); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id, PictureSize.Small), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_AlbumPicturesStillProcessing_FallsBackToTheOriginal() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id, pictures: EntityPictures.Pending("original.png")); + await SeedAsync(owner, album); - Assert.False(result.IsError); - Assert.Contains($"{owner.Id}", result.Value.Key); - Assert.EndsWith("original.png", result.Value.Key); - Assert.Equal("image/png", result.Value.ContentType); - } + var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id, PictureSize.Small), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_RemovingAlbum_ReturnsNotFound() - { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id); - album.LifeCycleStatus = LifeCycleStatus.Removing; - await SeedAsync(owner, album); + Assert.False(result.IsError); + Assert.Contains($"{owner.Id}", result.Value.Key); + Assert.EndsWith("original.png", result.Value.Key); + Assert.Equal("image/png", result.Value.ContentType); + } - var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_RemovingAlbum_ReturnsNotFound() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + album.LifeCycleStatus = LifeCycleStatus.Removing; + await SeedAsync(owner, album); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + var result = await CreateHandler().Handle(new GetCoverQuery(CoverOwner.Album, album.Id), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/AddTracksToPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/AddTracksToPlayListCommandHandlerTests.cs index f37c54e..81e228d 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/AddTracksToPlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/AddTracksToPlayListCommandHandlerTests.cs @@ -5,125 +5,126 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.PlayLists; - -public sealed class AddTracksToPlayListCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.PlayLists { - private AddTracksToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_FirstTrack_AddsItAtPositionZero() + public sealed class AddTracksToPlayListCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - var track = TestEntities.Track(owner); - await SeedAsync(owner, playList, track); + private AddTracksToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger()); - var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(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.Track(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 AddTracksToPlayListCommand(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.Track(owner, "Existing"); - var newTrack = TestEntities.Track(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 AddTracksToPlayListCommand(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.Track(owner, "Existing"); + var newTrack = TestEntities.Track(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 AddTracksToPlayListCommand(owner.Id, playList.Id, [newTrack.Id]), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PlayListMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(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 AddTracksToPlayListCommand(1, Guid.NewGuid(), [Guid.NewGuid()]), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_NotOwner_ReturnsForbidden() - { - 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 AddTracksToPlayListCommand(stranger.Id, playList.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NotOwner_ReturnsForbidden() + { + 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.Forbidden, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(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.Forbidden, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(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 AddTracksToPlayListCommand(owner.Id, playList.Id, [Guid.NewGuid()]), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_TrackAlreadyInPlayList_IsSkipped() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - var track = TestEntities.Track(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 AddTracksToPlayListCommand(owner.Id, playList.Id, [track.Id]), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TrackAlreadyInPlayList_IsSkipped() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var track = TestEntities.Track(owner); + await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); - Assert.False(result.IsError); - Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken)); - } + var result = await CreateHandler().Handle(new AddTracksToPlayListCommand(owner.Id, playList.Id, [track.Id]), TestContext.Current.CancellationToken); - [Fact] - public async Task TrySaveChangesAsync_DuplicatePlayListTrack_MapsTheUniqueViolationToTheGivenError() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - var track = TestEntities.Track(owner); - await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); - Database.PlayListHasTracks.Add(new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 1 }); + Assert.False(result.IsError); + Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken)); + } - var result = await Database.TrySaveChangesAsync(Error.Conflict("Test.Duplicate", "duplicate"), TestContext.Current.CancellationToken); + [Fact] + public async Task TrySaveChangesAsync_DuplicatePlayListTrack_MapsTheUniqueViolationToTheGivenError() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var track = TestEntities.Track(owner); + await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); + Database.PlayListHasTracks.Add(new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 1 }); - Assert.Equal("Test.Duplicate", result.FirstError.Code); - } + var result = await Database.TrySaveChangesAsync(Error.Conflict("Test.Duplicate", "duplicate"), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_SeveralTracks_AppendsThemInOrderSkippingExistingOnes() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - var existing = TestEntities.Track(owner, "Existing"); - var first = TestEntities.Track(owner, "First"); - var second = TestEntities.Track(owner, "Second"); - await SeedAsync( - owner, playList, existing, first, second, - new PlayListHasTrack { PlayListId = playList.Id, TrackId = existing.Id, Position = 0 }); + Assert.Equal("Test.Duplicate", result.FirstError.Code); + } - var result = await CreateHandler().Handle( - new AddTracksToPlayListCommand(owner.Id, playList.Id, [first.Id, existing.Id, second.Id, first.Id]), - TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_SeveralTracks_AppendsThemInOrderSkippingExistingOnes() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var existing = TestEntities.Track(owner, "Existing"); + var first = TestEntities.Track(owner, "First"); + var second = TestEntities.Track(owner, "Second"); + await SeedAsync( + owner, playList, existing, first, second, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = existing.Id, Position = 0 }); - Assert.False(result.IsError); - var links = await Database.PlayListHasTracks.OrderBy(link => link.Position).ToListAsync(TestContext.Current.CancellationToken); - Assert.Equal([existing.Id, first.Id, second.Id], links.Select(link => link.TrackId)); - Assert.Equal([0, 1, 2], links.Select(link => link.Position)); + var result = await CreateHandler().Handle( + new AddTracksToPlayListCommand(owner.Id, playList.Id, [first.Id, existing.Id, second.Id, first.Id]), + TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var links = await Database.PlayListHasTracks.OrderBy(link => link.Position).ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal([existing.Id, first.Id, second.Id], links.Select(link => link.TrackId)); + Assert.Equal([0, 1, 2], links.Select(link => link.Position)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs index 53ffc79..986c877 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs @@ -6,106 +6,107 @@ 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, TestConfigurations.UploadIntent()), - NoOpLogger(), - TestConfigurations.PlayList()); - - [Fact] - public async Task Handle_NoPictureIntent_CreatesPlayListWithoutPictures() + 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, TestConfigurations.UploadIntent()), + NoOpLogger(), + TestConfigurations.PlayList()); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NoPictureIntent_CreatesPlayListWithoutPictures() + { + var user = TestEntities.User(); + await SeedAsync(user); - Assert.False(result.IsError); - Assert.Equal("My Playlist", result.Value.Name); - Assert.Null(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.Null(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_NoVisibilitySpecified_DefaultsToPrivate() - { - 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, PictureIntentId: null); + 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 result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NoVisibilitySpecified_DefaultsToPrivate() + { + var user = TestEntities.User(); + await SeedAsync(user); - Assert.False(result.IsError); - Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Private, result.Value.Visibility); - } + var command = new CreatePlayListCommand(user.Id, "My Playlist", null, PictureIntentId: null); - [Fact] - public async Task Handle_PublicVisibility_PersistsIt() - { - 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, PictureIntentId: null, Visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public); + Assert.False(result.IsError); + Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Private, result.Value.Visibility); + } - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_PublicVisibility_PersistsIt() + { + var user = TestEntities.User(); + await SeedAsync(user); - Assert.False(result.IsError); - Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility); - } + var command = new CreatePlayListCommand(user.Id, "My Playlist", null, PictureIntentId: null, Visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public); - [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); + Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility); + } - 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 d8559c7..b451aa6 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_ReturnsForbidden() - { - 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.Forbidden, result.FirstError.Type); + [Fact] + public async Task Handle_NotOwner_ReturnsForbidden() + { + 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.Forbidden, result.FirstError.Type); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs index 1d7a5b7..ef95972 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs @@ -4,81 +4,82 @@ 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, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public); - var track = TestEntities.Track(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, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public); + var track = TestEntities.Track(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); - [Fact] - public async Task Handle_PrivatePlayList_RequestedByOwner_ReturnsIt() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Private); - await SeedAsync(owner, playList); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, owner.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_PrivatePlayList_RequestedByOwner_ReturnsIt() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Private); + await SeedAsync(owner, playList); - Assert.False(result.IsError); - } + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, owner.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PrivatePlayList_RequestedByStranger_ReturnsNotFound() - { - var owner = TestEntities.User(1, "owner"); - var stranger = TestEntities.User(2, "stranger"); - var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Private); - await SeedAsync(owner, stranger, playList); + Assert.False(result.IsError); + } - var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, stranger.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_PrivatePlayList_RequestedByStranger_ReturnsNotFound() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Private); + await SeedAsync(owner, stranger, playList); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, stranger.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PrivatePlayList_RequestedAnonymously_ReturnsNotFound() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Private); - await SeedAsync(owner, playList); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_PrivatePlayList_RequestedAnonymously_ReturnsNotFound() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Private); + await SeedAsync(owner, playList); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PublicPlayList_RequestedAnonymously_ReturnsIt() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public); - await SeedAsync(owner, playList); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_PublicPlayList_RequestedAnonymously_ReturnsIt() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public); + await SeedAsync(owner, playList); - Assert.False(result.IsError); + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs index f97d132..0589d68 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs @@ -5,59 +5,60 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; 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.Track(owner, "Second"); - var first = TestEntities.Track(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, ViewerId: owner.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TracksOrderedByPosition_ReturnsThemInOrder() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var second = TestEntities.Track(owner, "Second"); + var first = TestEntities.Track(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, ViewerId: owner.Id), 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); - [Fact] - public async Task Handle_PrivatePlayListOfAnotherUser_ReturnsNotFound() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: PlayListVisibility.Private); - await SeedAsync(owner, playList); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_PrivatePlayListOfAnotherUser_ReturnsNotFound() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: PlayListVisibility.Private); + await SeedAsync(owner, playList); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PrivatePlayListOfViewer_ReturnsTracks() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: PlayListVisibility.Private); - await SeedAsync(owner, playList); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_PrivatePlayListOfViewer_ReturnsTracks() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: PlayListVisibility.Private); + await SeedAsync(owner, playList); - Assert.False(result.IsError); + var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs index 76b0a75..4e1d4b9 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs @@ -3,63 +3,64 @@ 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, ViewerId: owner.Id), TestContext.Current.CancellationToken); - var playList = Assert.Single(result.Items); - Assert.Equal("Mine", playList.Name); - } + [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")); - [Fact] - public async Task Handle_AnotherViewer_ReturnsOnlyPublicPlayLists() - { - var owner = TestEntities.User(); - await SeedAsync( - owner, - TestEntities.PlayList(owner.Id, "Public one", visibility: PlayListVisibility.Public), - TestEntities.PlayList(owner.Id, "Private one", visibility: PlayListVisibility.Private)); + var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); + var playList = Assert.Single(result.Items); + Assert.Equal("Mine", playList.Name); + } - var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken); - var playList = Assert.Single(result.Items); - Assert.Equal("Public one", playList.Name); - } + [Fact] + public async Task Handle_AnotherViewer_ReturnsOnlyPublicPlayLists() + { + var owner = TestEntities.User(); + await SeedAsync( + owner, + TestEntities.PlayList(owner.Id, "Public one", visibility: PlayListVisibility.Public), + TestEntities.PlayList(owner.Id, "Private one", visibility: PlayListVisibility.Private)); - [Fact] - public async Task Handle_Owner_SeesPrivatePlayLists() - { - var owner = TestEntities.User(); - await SeedAsync( - owner, - TestEntities.PlayList(owner.Id, "Public one", visibility: PlayListVisibility.Public), - TestEntities.PlayList(owner.Id, "Private one", visibility: PlayListVisibility.Private)); + var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken); + var playList = Assert.Single(result.Items); + Assert.Equal("Public one", playList.Name); + } - var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); - Assert.Equal(2, result.Items.Count); - } + [Fact] + public async Task Handle_Owner_SeesPrivatePlayLists() + { + var owner = TestEntities.User(); + await SeedAsync( + owner, + TestEntities.PlayList(owner.Id, "Public one", visibility: PlayListVisibility.Public), + TestEntities.PlayList(owner.Id, "Private one", visibility: PlayListVisibility.Private)); - [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")); + var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); + Assert.Equal(2, result.Items.Count); + } - var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: "road", PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); - var playList = Assert.Single(result.Items); - Assert.Equal("Road Trip", playList.Name); + [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")); + + var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: "road", PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); + var playList = Assert.Single(result.Items); + Assert.Equal("Road Trip", playList.Name); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs index c996a45..e73a127 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs @@ -4,78 +4,79 @@ 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.Track(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.Track(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_ReturnsForbidden() - { - 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_ReturnsForbidden() + { + 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.Forbidden, 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.Forbidden, 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); - [Fact] - public async Task Handle_TrackInTheMiddle_RenumbersTheRemainingTracks() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - var first = TestEntities.Track(owner, "First"); - var second = TestEntities.Track(owner, "Second"); - var third = TestEntities.Track(owner, "Third"); - await SeedAsync( - owner, playList, first, second, third, - new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 }, - new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 }, - new PlayListHasTrack { PlayListId = playList.Id, TrackId = third.Id, Position = 2 }); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, second.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TrackInTheMiddle_RenumbersTheRemainingTracks() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var first = TestEntities.Track(owner, "First"); + var second = TestEntities.Track(owner, "Second"); + var third = TestEntities.Track(owner, "Third"); + await SeedAsync( + owner, playList, first, second, third, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 }, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 }, + new PlayListHasTrack { PlayListId = playList.Id, TrackId = third.Id, Position = 2 }); - Assert.False(result.IsError); - var remaining = Database.PlayListHasTracks.OrderBy(link => link.Position).ToList(); - Assert.Equal([first.Id, third.Id], remaining.Select(link => link.TrackId)); - Assert.Equal([0, 1], remaining.Select(link => link.Position)); + var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, second.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var remaining = Database.PlayListHasTracks.OrderBy(link => link.Position).ToList(); + Assert.Equal([first.Id, third.Id], remaining.Select(link => link.TrackId)); + Assert.Equal([0, 1], remaining.Select(link => link.Position)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs index 928762e..1b967bc 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs @@ -8,70 +8,71 @@ 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(), Arg.Any()) - .Returns("https://storage.musify.test/presigned-upload"); + private readonly IStorageService storageService = Substitute.For(); - var config = uploadIntentConfig ?? TestConfigurations.UploadIntent(); + private RequestPlayListPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) + { + storageService + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://storage.musify.test/presigned-upload"); - return new RequestPlayListPictureUploadCommandHandler( - new UploadIntentIssuer( - Database, - storageService, - new UploadIntentValidator(Database, storageService, config), - TestConfigurations.Storage(), - config, - NoOpLogger()), - TestConfigurations.PlayList()); - } + var config = uploadIntentConfig ?? TestConfigurations.UploadIntent(); - [Fact] - public async Task Handle_UserExists_IssuesUploadUrlAndCreatesIntent() - { - var user = TestEntities.User(); - await SeedAsync(user); + return new RequestPlayListPictureUploadCommandHandler( + new UploadIntentIssuer( + Database, + storageService, + new UploadIntentValidator(Database, storageService, config), + TestConfigurations.Storage(), + config, + NoOpLogger()), + TestConfigurations.PlayList()); + } - 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 c151868..f7951bf 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs @@ -6,128 +6,129 @@ 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, TestConfigurations.UploadIntent()), - NoOpLogger(), - TestConfigurations.PlayList()); - - [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, TestConfigurations.UploadIntent()), + NoOpLogger(), + TestConfigurations.PlayList()); - 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_ReturnsForbidden() - { - 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_ReturnsForbidden() + { + 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.Forbidden, result.FirstError.Type); - } + var command = new UpdatePlayListCommand(stranger.Id, playList.Id, "Hijacked", null, null); - [Fact] - public async Task Handle_NewVisibility_UpdatesIt() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, playList); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, null, Musify.Domain.ValueObjects.PlayListVisibility.Public); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NewVisibility_UpdatesIt() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, playList); - Assert.False(result.IsError); - Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility); - } + var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, null, Musify.Domain.ValueObjects.PlayListVisibility.Public); - [Fact] - public async Task Handle_NoVisibilitySpecified_LeavesItUnchanged() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public); - await SeedAsync(owner, playList); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null); + Assert.False(result.IsError); + Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility); + } - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NoVisibilitySpecified_LeavesItUnchanged() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlayListVisibility.Public); + await SeedAsync(owner, playList); - Assert.False(result.IsError); - Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility); - } + var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null); - [Fact] - public async Task Handle_BlankName_LeavesItUnchangedAndBlankDescriptionClearsIt() - { - 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.False(result.IsError); + Assert.Equal(Musify.Domain.ValueObjects.PlayListVisibility.Public, result.Value.Visibility); + } - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_BlankName_LeavesItUnchangedAndBlankDescriptionClearsIt() + { + 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.Null(result.Value.Description); - } + var command = new UpdatePlayListCommand(owner.Id, playList.Id, " ", " ", null); - [Fact] - public async Task Handle_PlayListWithoutPictures_ValidPictureIntent_SetsPictureAndPublishesSourceEvent() - { - storageService - .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ObjectMetaData("image/webp", 1024)); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - playList.Pictures = null; - var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.PlayListPicture, objectName: "new-cover.webp"); - await SeedAsync(owner, playList, intent); + Assert.False(result.IsError); + Assert.Equal("Original Name", result.Value.Name); + Assert.Null(result.Value.Description); + } - var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, intent.Id); + [Fact] + public async Task Handle_PlayListWithoutPictures_ValidPictureIntent_SetsPictureAndPublishesSourceEvent() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + playList.Pictures = null; + var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.PlayListPicture, objectName: "new-cover.webp"); + await SeedAsync(owner, playList, intent); - Assert.False(result.IsError); - Assert.Null(result.Value.SmallImageKeyName); - var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName); - await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, intent.Id); + + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Null(result.Value.SmallImageKeyName); + var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs b/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs index 21b4d47..eaaf27b 100644 --- a/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs @@ -5,59 +5,60 @@ using Musify.Application.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Services; - -public sealed class TrackStreamIssuerTests +namespace Musify.Application.Tests.Services { - private readonly IStreamTicketService ticketService = Substitute.For(); - - public TrackStreamIssuerTests() => - ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("signed-token", 120)); - - private TrackStreamIssuer CreateIssuer(PlaybackConfiguration? playback = null) => new( - ticketService, - TestConfigurations.Track(), - TestConfigurations.StreamGateway("https://stream.musify.test/"), - playback ?? TestConfigurations.Playback()); - - [Fact] - public void Issue_BuildsManifestUrlAndCarriesTheListenId() + public sealed class TrackStreamIssuerTests { - var listenId = Guid.NewGuid(); + private readonly IStreamTicketService ticketService = Substitute.For(); - var response = CreateIssuer().Issue("folder-42", userId: 7, listenId); + public TrackStreamIssuerTests() => + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("signed-token", 120)); - Assert.Equal("signed-token", response.Ticket); - Assert.Equal(120, response.ExpiresInSeconds); - Assert.Equal(listenId, response.ListenId); - Assert.Equal("https://stream.musify.test/media/Tracks/ProcessedAudios/folder-42/audio.m4a", response.ManifestUrl); - } + private TrackStreamIssuer CreateIssuer(PlaybackConfiguration? playback = null) => new( + ticketService, + TestConfigurations.Track(), + TestConfigurations.StreamGateway("https://stream.musify.test/"), + playback ?? TestConfigurations.Playback()); - [Fact] - public void Issue_PassesTheProcessedAudioKeyPrefixToTheTicketService() - { - CreateIssuer().Issue("folder-42", userId: 1, listenId: null); + [Fact] + public void Issue_BuildsManifestUrlAndCarriesTheListenId() + { + var listenId = Guid.NewGuid(); - ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null); - } + var response = CreateIssuer().Issue("folder-42", userId: 7, listenId); - [Fact] - public void Issue_AnonymousUserWithFragmentConfigured_PassesTheByteCapToTheTicketService() - { - var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); + Assert.Equal("signed-token", response.Ticket); + Assert.Equal(120, response.ExpiresInSeconds); + Assert.Equal(listenId, response.ListenId); + Assert.Equal("https://stream.musify.test/media/Tracks/ProcessedAudios/folder-42/audio.m4a", response.ManifestUrl); + } - CreateIssuer(playback).Issue("folder-42", userId: null, listenId: null); + [Fact] + public void Issue_PassesTheProcessedAudioKeyPrefixToTheTicketService() + { + CreateIssuer().Issue("folder-42", userId: 1, listenId: null); - ticketService.Received(1).IssueTicket(null, "Tracks/ProcessedAudios/folder-42/", 30L * playback.EstimatedAudioBytesPerSecond); - } + ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null); + } - [Fact] - public void Issue_AuthenticatedUser_IgnoresTheAnonymousFragmentCap() - { - var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); + [Fact] + public void Issue_AnonymousUserWithFragmentConfigured_PassesTheByteCapToTheTicketService() + { + var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); - CreateIssuer(playback).Issue("folder-42", userId: 1, listenId: null); + CreateIssuer(playback).Issue("folder-42", userId: null, listenId: null); - ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null); + ticketService.Received(1).IssueTicket(null, "Tracks/ProcessedAudios/folder-42/", 30L * playback.EstimatedAudioBytesPerSecond); + } + + [Fact] + public void Issue_AuthenticatedUser_IgnoresTheAnonymousFragmentCap() + { + var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); + + CreateIssuer(playback).Issue("folder-42", userId: 1, listenId: null); + + ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs b/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs index 95967e3..fc439b9 100644 --- a/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs +++ b/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs @@ -7,152 +7,153 @@ 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(UploadIntentConfiguration? config = null) => - new(Database, storageService, config ?? TestConfigurations.UploadIntent()); - - [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(UploadIntentConfiguration? config = null) => + new(Database, storageService, config ?? TestConfigurations.UploadIntent()); - var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, 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_IntentIssuedForAnotherPurpose_ReturnsValidationError() - { - var intent = TestEntities.UploadIntent(owner.Id, UploadIntentPurpose.TrackAudio); - await SeedAsync(owner, intent); + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); - var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(intent.Id, result.Value.Id); + } - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } + [Fact] + public async Task ValidateAndLoadAsync_IntentIssuedForAnotherPurpose_ReturnsValidationError() + { + var intent = TestEntities.UploadIntent(owner.Id, UploadIntentPurpose.TrackAudio); + await SeedAsync(owner, intent); - [Fact] - public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound() - { - var result = await CreateValidator().ValidateAndLoadAsync(Guid.NewGuid(), owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + Assert.Equal(ErrorType.Validation, 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_IntentMissing_ReturnsNotFound() + { + var result = await CreateValidator().ValidateAndLoadAsync(Guid.NewGuid(), owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); - var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, stranger.Id, UploadIntentPurpose.TrackPicture, 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(intent.Id, stranger.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); - var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, 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(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); - var result = await CreateValidator().ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, 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(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, 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(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, 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(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, 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(config).ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, 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(config).ValidateAndLoadAsync(intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); - var result = await CreateValidator().CheckQuotaAsync(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(owner.Id, requiredBytes: 1024, requiredIntentCount: 1, TestContext.Current.CancellationToken); - var result = await CreateValidator(config).CheckQuotaAsync(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(config).CheckQuotaAsync(owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken); - var result = await CreateValidator(config).CheckQuotaAsync(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(config).CheckQuotaAsync(owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + } } } 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 4ad7a9e..dae34fd 100644 --- a/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs +++ b/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs @@ -2,31 +2,32 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks.Responses; using Xunit; -namespace Musify.Application.Tests.Shared; - -public sealed class TrackApplicationResponseTests +namespace Musify.Application.Tests.Shared { - [Fact] - public void FromEntity_UsesOwnerNameAsArtist() + public sealed class TrackApplicationResponseTests { - var owner = TestEntities.User(1, "The Owner"); - var track = TestEntities.Track(owner); + [Fact] + public void FromEntity_UsesOwnerNameAsArtist() + { + var owner = TestEntities.User(1, "The Owner"); + var track = TestEntities.Track(owner); - var response = TrackApplicationResponse.FromEntity(track, listensCount: 3); + var response = TrackApplicationResponse.FromEntity(track, listensCount: 3); - Assert.Equal("The Owner", response.Artist); - Assert.Equal(owner.Id, response.OwnerUserId); - Assert.Equal(3, response.ListensCount); - Assert.False(response.IsExplicit); - } + Assert.Equal("The Owner", response.Artist); + Assert.Equal(owner.Id, response.OwnerUserId); + Assert.Equal(3, response.ListensCount); + Assert.False(response.IsExplicit); + } - [Fact] - public void FromEntity_CopiesExplicitFlag() - { - var track = TestEntities.Track(TestEntities.User(), isExplicit: true); + [Fact] + public void FromEntity_CopiesExplicitFlag() + { + var track = TestEntities.Track(TestEntities.User(), isExplicit: true); - var response = TrackApplicationResponse.FromEntity(track, listensCount: 0); + var response = TrackApplicationResponse.FromEntity(track, listensCount: 0); - Assert.True(response.IsExplicit); + Assert.True(response.IsExplicit); + } } } diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs index 544efda..6926283 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs @@ -2,29 +2,30 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Xunit; -namespace Musify.Application.Tests.TestSupport; - -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() + public abstract class HandlerTestBase : IAsyncLifetime { - GC.SuppressFinalize(this); - return database.DisposeAsync(); - } + private TestDatabase database = null!; - protected static ILogger NoOpLogger() => NullLogger.Instance; + protected TestDatabase Database => database; - protected async Task SeedAsync(params object[] entities) - { - foreach (var entity in entities) - Database.Add(entity); + public async ValueTask InitializeAsync() => database = await TestDatabase.CreateAsync(); - await Database.SaveChangesAsync(CancellationToken.None); + public ValueTask DisposeAsync() + { + GC.SuppressFinalize(this); + return database.DisposeAsync(); + } + + protected static ILogger NoOpLogger() => NullLogger.Instance; + + 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 56bb542..2d45ebe 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs @@ -1,25 +1,26 @@ using Musify.Application.Configuration; -namespace Musify.Application.Tests.TestSupport; - -public static class TestConfigurations +namespace Musify.Application.Tests.TestSupport { - public static ApplicationStorageConfiguration Storage(string bucket = "test-bucket") => - new() { Bucket = bucket }; + 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 AlbumConfiguration Album() => new(); + public static AlbumConfiguration Album() => 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 }; - public static PlaybackConfiguration Playback(bool allowAnonymousListening = false, int anonymousFragmentSeconds = 0) => - new() { AllowAnonymousListening = allowAnonymousListening, AnonymousFragmentSeconds = anonymousFragmentSeconds }; + public static PlaybackConfiguration Playback(bool allowAnonymousListening = false, int anonymousFragmentSeconds = 0) => + new() { AllowAnonymousListening = allowAnonymousListening, AnonymousFragmentSeconds = anonymousFragmentSeconds }; + } } diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs index b995a40..a08d919 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs @@ -5,96 +5,97 @@ using Musify.Application.Contracts; using Musify.Domain.Entities; using Musify.Infrastructure.Persistence; -namespace Musify.Application.Tests.TestSupport; - -public sealed class TestDatabase : DbContext, IDatabase +namespace Musify.Application.Tests.TestSupport { - private readonly SqliteConnection connection; - - private TestDatabase(SqliteConnection connection, DbContextOptions options) - : base(options) + public sealed class TestDatabase : DbContext, IDatabase { - this.connection = connection; - } + private readonly SqliteConnection connection; - public static async Task CreateAsync() - { - var connection = new SqliteConnection("Filename=:memory:"); - await connection.OpenAsync(); - - var options = new DbContextOptionsBuilder() - .UseSqlite(connection) - .Options; - - var database = new TestDatabase(connection, options); - await database.Database.EnsureCreatedAsync(); - return database; - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - ModelConfiguration.Apply(modelBuilder); - } - - public DbSet Users => Set(); - - public DbSet PlayLists => Set(); - - public DbSet Albums => Set(); - - public DbSet AlbumHasTracks => Set(); - - public DbSet Tracks => Set(); - - public DbSet PlayListHasTracks => Set(); - - public DbSet Mixes => Set(); - - public DbSet MixItems => Set(); - - public DbSet UploadIntents => Set(); - - public DbSet ListeningHistories => Set(); - - public DbSet TrackLikes => Set(); - - public DbSet TrackTags => Set(); - - public DbSet UserFollows => Set(); - - public async Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) - { - var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); - return new SqliteDatabaseTransaction(transaction); - } - - public async Task> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken) - { - try + private TestDatabase(SqliteConnection connection, DbContextOptions options) + : base(options) { - await SaveChangesAsync(cancellationToken); - return Result.Success; + this.connection = connection; } - catch (DbUpdateException exception) when (exception.InnerException is SqliteException { SqliteErrorCode: 19 }) + + public static async Task CreateAsync() { - return onUniqueViolation; + var connection = new SqliteConnection("Filename=:memory:"); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + var database = new TestDatabase(connection, options); + await database.Database.EnsureCreatedAsync(); + return database; } - } - public override async ValueTask DisposeAsync() - { - await base.DisposeAsync(); - await connection.DisposeAsync(); - } + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); - private sealed class SqliteDatabaseTransaction(Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction inner) - : IDatabaseTransaction - { - public Task CommitAsync(CancellationToken cancellationToken = default) => - inner.CommitAsync(cancellationToken); + ModelConfiguration.Apply(modelBuilder); + } - public ValueTask DisposeAsync() => inner.DisposeAsync(); + public DbSet Users => Set(); + + public DbSet PlayLists => Set(); + + public DbSet Albums => Set(); + + public DbSet AlbumHasTracks => Set(); + + public DbSet Tracks => Set(); + + public DbSet PlayListHasTracks => Set(); + + public DbSet Mixes => Set(); + + public DbSet MixItems => Set(); + + public DbSet UploadIntents => Set(); + + public DbSet ListeningHistories => Set(); + + public DbSet TrackLikes => Set(); + + public DbSet TrackTags => Set(); + + public DbSet UserFollows => Set(); + + public async Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) + { + var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); + return new SqliteDatabaseTransaction(transaction); + } + + public async Task> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken) + { + try + { + await SaveChangesAsync(cancellationToken); + return Result.Success; + } + catch (DbUpdateException exception) when (exception.InnerException is SqliteException { SqliteErrorCode: 19 }) + { + return onUniqueViolation; + } + } + + 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 3aeaca5..1d1d104 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs @@ -1,191 +1,192 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Tests.TestSupport; - -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() + public static class TestEntities + { + 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() { - Id = id, - Name = name, - NormalizedName = name.ToUpperInvariant(), - FirstName = firstName, - SecondName = secondName, - ProfilePictureUrl = profilePictureUrl + OriginalName = "original.webp", + SmallName = "small.webp", + MediumName = "medium.webp", + LargeName = "large.webp", + ProcessingStatus = ProcessingStatus.Completed }; - public static TrackPictures ProcessedPictures() => new() - { - OriginalName = "original.webp", - SmallName = "small.webp", - MediumName = "medium.webp", - LargeName = "large.webp", - ProcessingStatus = ProcessingStatus.Completed - }; - - 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 Track Track( - User owner, - string title = "Test Track", - double durationSeconds = 180, - TrackPictures? pictures = null, - TrackAudio? audio = null, - LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active, - IEnumerable? tags = null, - bool isExplicit = false) - { - var track = new Track + public static TrackPictures PendingPictures() => new() { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - DurationSeconds = durationSeconds, - LifeCycleStatus = lifeCycleStatus, - IsExplicit = isExplicit, - OwnerUserId = owner.Id, - Owner = owner, - Pictures = pictures ?? ProcessedPictures(), - Audio = audio ?? ProcessedAudio() + OriginalName = "original.webp", + ProcessingStatus = ProcessingStatus.Pending }; - track.Tags = (tags ?? [Genre.Pop]) - .Select(tag => new TrackTag { TrackId = track.Id, Tag = tag }) - .ToList(); + public static TrackAudio ProcessedAudio(string folderName = "audio-folder") => new() + { + OriginalName = "original.mp3", + FolderName = folderName, + TranscodeStatus = ProcessingStatus.Completed + }; - return track; + public static TrackAudio PendingAudio() => new() + { + OriginalName = "original.mp3", + TranscodeStatus = ProcessingStatus.Pending + }; + + public static Track Track( + User owner, + string title = "Test Track", + double durationSeconds = 180, + TrackPictures? pictures = null, + TrackAudio? audio = null, + LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active, + IEnumerable? tags = null, + bool isExplicit = false) + { + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + DurationSeconds = durationSeconds, + LifeCycleStatus = lifeCycleStatus, + IsExplicit = isExplicit, + OwnerUserId = owner.Id, + Owner = owner, + Pictures = pictures ?? ProcessedPictures(), + Audio = audio ?? ProcessedAudio() + }; + + track.Tags = (tags ?? [Genre.Pop]) + .Select(tag => new TrackTag { TrackId = track.Id, Tag = tag }) + .ToList(); + + return track; + } + + public static EntityPictures 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, + EntityPictures? pictures = null, + LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active, + PlayListVisibility visibility = PlayListVisibility.Private) => + new() + { + OwnerUserId = userId, + Name = name, + NormalizedName = name.ToUpperInvariant(), + Description = description, + Pictures = pictures ?? PlayListPictures(), + LifeCycleStatus = lifeCycleStatus, + Visibility = visibility + }; + + public static EntityPictures AlbumPictures() => new() + { + OriginalName = "original.webp", + SmallName = "small.webp", + MediumName = "medium.webp", + LargeName = "large.webp" + }; + + public static Album Album( + long ownerUserId, + string title = "Test Album", + string? description = null, + int? releaseYear = 2024, + EntityPictures? pictures = null) => + new() + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + Description = description, + ReleaseYear = releaseYear, + OwnerUserId = ownerUserId, + Pictures = pictures ?? AlbumPictures() + }; + + 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 Mix Mix( + long userId, + MixKind kind = MixKind.Discovery, + int position = 0) => + new() + { + UserId = userId, + Kind = kind, + Position = position + }; + + public static MixItem MixItem( + Guid mixId, + Guid trackId, + int position = 0) => + new() + { + MixId = mixId, + Position = position, + TrackId = trackId + }; + + public static ListeningHistory ListeningHistory( + long userId, + Guid trackId, + DateTime? listenedAt = null, + double? playedSeconds = null, + bool isCounted = true) => + new() + { + UserId = userId, + TrackId = trackId, + ListenedAt = listenedAt ?? DateTime.UtcNow, + PlayedSeconds = playedSeconds, + IsCounted = isCounted + }; } - - public static EntityPictures 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, - EntityPictures? pictures = null, - LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active, - PlayListVisibility visibility = PlayListVisibility.Private) => - new() - { - OwnerUserId = userId, - Name = name, - NormalizedName = name.ToUpperInvariant(), - Description = description, - Pictures = pictures ?? PlayListPictures(), - LifeCycleStatus = lifeCycleStatus, - Visibility = visibility - }; - - public static EntityPictures AlbumPictures() => new() - { - OriginalName = "original.webp", - SmallName = "small.webp", - MediumName = "medium.webp", - LargeName = "large.webp" - }; - - public static Album Album( - long ownerUserId, - string title = "Test Album", - string? description = null, - int? releaseYear = 2024, - EntityPictures? pictures = null) => - new() - { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - Description = description, - ReleaseYear = releaseYear, - OwnerUserId = ownerUserId, - Pictures = pictures ?? AlbumPictures() - }; - - 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 Mix Mix( - long userId, - MixKind kind = MixKind.Discovery, - int position = 0) => - new() - { - UserId = userId, - Kind = kind, - Position = position - }; - - public static MixItem MixItem( - Guid mixId, - Guid trackId, - int position = 0) => - new() - { - MixId = mixId, - Position = position, - TrackId = trackId - }; - - public static ListeningHistory ListeningHistory( - long userId, - Guid trackId, - DateTime? listenedAt = null, - double? playedSeconds = null, - bool isCounted = true) => - new() - { - UserId = userId, - TrackId = trackId, - ListenedAt = listenedAt ?? DateTime.UtcNow, - PlayedSeconds = playedSeconds, - IsCounted = isCounted - }; } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs index ecb346d..59a74c3 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs @@ -8,125 +8,126 @@ 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, TestConfigurations.UploadIntent()), - NoOpLogger(), - TestConfigurations.Track()); - } + 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, TestConfigurations.UploadIntent()), + NoOpLogger(), + TestConfigurations.Track()); + } - [Fact] - public async Task Handle_SameIntentForPictureAndAudio_ReturnsValidationError() - { - 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, picture.Id, [Genre.Pop]); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_SameIntentForPictureAndAudio_ReturnsValidationError() + { + var user = TestEntities.User(); + var (picture, audio) = SeedIntents(user.Id); + await SeedAsync(user, picture, audio); - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } + var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, picture.Id, [Genre.Pop]); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_ValidIntents_CreatesTrackAndPublishesEvent() - { - var user = TestEntities.User(); - var (picture, audio) = SeedIntents(user.Id); - await SeedAsync(user, picture, audio); + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } - var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop, Genre.Rock]); + [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, [Genre.Pop, Genre.Rock]); - Assert.False(result.IsError); - Assert.Equal("My Song", result.Value.Title); - var localResponse = Assert.IsType(result.Value); - Assert.Equal(user.Id, localResponse.OwnerUserId); - Assert.Equal([Genre.Pop, Genre.Rock], localResponse.Tags); - await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var stored = await Database.Tracks.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); + var localResponse = Assert.IsType(result.Value); + Assert.Equal(user.Id, localResponse.OwnerUserId); + Assert.Equal([Genre.Pop, Genre.Rock], localResponse.Tags); + await eventBus.Received(1).PublishAsync(Arg.Any(), Arg.Any()); - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task Handle_StoresExplicitFlag(bool isExplicit) - { - var user = TestEntities.User(); - var (picture, audio) = SeedIntents(user.Id); - await SeedAsync(user, picture, audio); + var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus); + } - var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop], isExplicit); + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Handle_StoresExplicitFlag(bool isExplicit) + { + 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, [Genre.Pop], isExplicit); - Assert.False(result.IsError); - Assert.Equal(isExplicit, result.Value.IsExplicit); - var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal(isExplicit, stored.IsExplicit); - } + 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(), [Genre.Pop]); + Assert.False(result.IsError); + Assert.Equal(isExplicit, result.Value.IsExplicit); + var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal(isExplicit, stored.IsExplicit); + } - 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(), [Genre.Pop]); - 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, [Genre.Pop]); + [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, [Genre.Pop]); - 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, [Genre.Pop]); + [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, [Genre.Pop]); - 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/DeleteStaleUncountedListensCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/DeleteStaleUncountedListensCommandHandlerTests.cs index 41b449f..184f3d5 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/DeleteStaleUncountedListensCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/DeleteStaleUncountedListensCommandHandlerTests.cs @@ -3,29 +3,30 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; using Xunit; -namespace Musify.Application.Tests.Tracks; - -public sealed class DeleteStaleUncountedListensCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Tracks { - private DeleteStaleUncountedListensCommandHandler CreateHandler() => - new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_DeletesOnlyOldUncountedListens() + public sealed class DeleteStaleUncountedListensCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - var oldUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 2, isCounted: false); - var oldCounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 100); - var recentUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-2), playedSeconds: 2, isCounted: false); - await SeedAsync(owner, track, oldUncounted, oldCounted, recentUncounted); + private DeleteStaleUncountedListensCommandHandler CreateHandler() => + new(Database, NoOpLogger()); - var deleted = await CreateHandler().Handle(new DeleteStaleUncountedListensCommand(), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_DeletesOnlyOldUncountedListens() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + var oldUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 2, isCounted: false); + var oldCounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 100); + var recentUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-2), playedSeconds: 2, isCounted: false); + await SeedAsync(owner, track, oldUncounted, oldCounted, recentUncounted); - Assert.Equal(1, deleted); - var remaining = await Database.ListeningHistories.Select(l => l.Id).ToListAsync(TestContext.Current.CancellationToken); - Assert.DoesNotContain(oldUncounted.Id, remaining); - Assert.Contains(oldCounted.Id, remaining); - Assert.Contains(recentUncounted.Id, remaining); + var deleted = await CreateHandler().Handle(new DeleteStaleUncountedListensCommand(), TestContext.Current.CancellationToken); + + Assert.Equal(1, deleted); + var remaining = await Database.ListeningHistories.Select(l => l.Id).ToListAsync(TestContext.Current.CancellationToken); + Assert.DoesNotContain(oldUncounted.Id, remaining); + Assert.Contains(oldCounted.Id, remaining); + Assert.Contains(recentUncounted.Id, remaining); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs index 4052fe0..9af73d7 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs @@ -6,60 +6,61 @@ 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.Track(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.Track(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_ReturnsForbidden() - { - var owner = TestEntities.User(1, "owner"); - var stranger = TestEntities.User(2, "stranger"); - var track = TestEntities.Track(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.Forbidden, result.FirstError.Type); - } + [Fact] + public async Task Handle_NotOwner_ReturnsForbidden() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var track = TestEntities.Track(owner); + await SeedAsync(owner, stranger, track); - [Fact] - public async Task Handle_AudioStillProcessing_ReturnsConflict() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio()); - await SeedAsync(owner, track); + var result = await CreateHandler().Handle(new DeleteTrackCommand(stranger.Id, track.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + [Fact] + public async Task Handle_AudioStillProcessing_ReturnsConflict() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(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/GetLastTrackListenedByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs index 822db75..8e80f4a 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs @@ -2,37 +2,38 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; using Xunit; -namespace Musify.Application.Tests.Tracks; - -public sealed class GetLastTrackListenedByUserIdQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Tracks { - private GetLastTrackListenedByUserIdQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_MultipleListens_ReturnsMostRecentlyListenedTrack() + public sealed class GetLastTrackListenedByUserIdQueryHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var older = TestEntities.Track(owner, "Older listen"); - var newer = TestEntities.Track(owner, "Newer listen"); - await SeedAsync( - owner, older, newer, - TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-10)), - TestEntities.ListeningHistory(owner.Id, newer.Id, DateTime.UtcNow)); + private GetLastTrackListenedByUserIdQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_MultipleListens_ReturnsMostRecentlyListenedTrack() + { + var owner = TestEntities.User(); + var older = TestEntities.Track(owner, "Older listen"); + var newer = TestEntities.Track(owner, "Newer listen"); + await SeedAsync( + owner, older, newer, + TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-10)), + TestEntities.ListeningHistory(owner.Id, newer.Id, DateTime.UtcNow)); - Assert.NotNull(result); - Assert.Equal("Newer listen", result.Title); - } + var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_NoHistory_ReturnsNull() - { - var owner = TestEntities.User(); - await SeedAsync(owner); + Assert.NotNull(result); + Assert.Equal("Newer listen", result.Title); + } - var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NoHistory_ReturnsNull() + { + var owner = TestEntities.User(); + await SeedAsync(owner); - Assert.Null(result); + var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); + + Assert.Null(result); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs index d9cf654..1886f27 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs @@ -2,53 +2,54 @@ 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.Track(owner, "Older listen"); - var newer = TestEntities.Track(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.Track(owner, "Older listen"); + var newer = TestEntities.Track(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); - [Fact] - public async Task Handle_UncountedListens_AreIgnored() - { - var owner = TestEntities.User(); - var counted = TestEntities.Track(owner, "Counted"); - var skipped = TestEntities.Track(owner, "Skipped"); - await SeedAsync( - owner, counted, skipped, - TestEntities.ListeningHistory(owner.Id, counted.Id, DateTime.UtcNow.AddMinutes(-5), playedSeconds: 90), - TestEntities.ListeningHistory(owner.Id, skipped.Id, DateTime.UtcNow, playedSeconds: 2, isCounted: false)); + Assert.Empty(result); + } - var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_UncountedListens_AreIgnored() + { + var owner = TestEntities.User(); + var counted = TestEntities.Track(owner, "Counted"); + var skipped = TestEntities.Track(owner, "Skipped"); + await SeedAsync( + owner, counted, skipped, + TestEntities.ListeningHistory(owner.Id, counted.Id, DateTime.UtcNow.AddMinutes(-5), playedSeconds: 90), + TestEntities.ListeningHistory(owner.Id, skipped.Id, DateTime.UtcNow, playedSeconds: 2, isCounted: false)); - Assert.Equal(["Counted"], result.Select(track => track.Title)); + var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken); + + Assert.Equal(["Counted"], result.Select(track => track.Title)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs index 93a1548..4a5798a 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs @@ -4,49 +4,50 @@ using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; 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.Track(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.Track(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); - var localResponse = Assert.IsType(result.Value); - Assert.Equal(owner.Id, localResponse.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); + var localResponse = Assert.IsType(result.Value); + Assert.Equal(owner.Id, localResponse.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); - [Fact] - public async Task Handle_ListensCount_OnlyCountsCountedListens() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync( - owner, track, - TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100), - TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100), - TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 1, isCounted: false)); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ListensCount_OnlyCountsCountedListens() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync( + owner, track, + TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100), + TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100), + TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 1, isCounted: false)); - Assert.Equal(2, result.Value.ListensCount); + var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken); + + Assert.Equal(2, result.Value.ListensCount); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs index 4217572..8a71777 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs @@ -2,26 +2,27 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; 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.Track(owner, "Mine"); - var theirs = TestEntities.Track(other, "Theirs"); - await SeedAsync( - owner, other, mine, theirs); + 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.Track(owner, "Mine"); + var theirs = TestEntities.Track(other, "Theirs"); + await SeedAsync( + owner, other, mine, theirs); - 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); + + Assert.False(result.IsError); + var track = Assert.Single(result.Value.Items); + Assert.Equal("Mine", track.Title); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs index eee38d9..0d5e7b8 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs @@ -3,51 +3,52 @@ using Musify.Application.Tracks; using Musify.Domain.ValueObjects; 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.Track(owner, "Alpha"); - var trackB = TestEntities.Track(owner, "Beta"); - await SeedAsync( - owner, trackA, trackB); + private GetTracksQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetTracksQuery(Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - Assert.Equal(2, result.TotalItemCount); - Assert.Equal(2, result.Items.Count); - } + [Fact] + public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns() + { + var owner = TestEntities.User(); + var trackA = TestEntities.Track(owner, "Alpha"); + var trackB = TestEntities.Track(owner, "Beta"); + await SeedAsync( + owner, trackA, trackB); - [Fact] - public async Task Handle_NameFilter_ReturnsOnlyMatchingTracks() - { - var owner = TestEntities.User(); - var match = TestEntities.Track(owner, "Bohemian Rhapsody"); - var other = TestEntities.Track(owner, "Imagine"); - await SeedAsync( - owner, match, other); + var result = await CreateHandler().Handle(new GetTracksQuery(Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + Assert.Equal(2, result.TotalItemCount); + Assert.Equal(2, result.Items.Count); + } - var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - var item = Assert.Single(result.Items); - Assert.Equal("Bohemian Rhapsody", item.Title); - } + [Fact] + public async Task Handle_NameFilter_ReturnsOnlyMatchingTracks() + { + var owner = TestEntities.User(); + var match = TestEntities.Track(owner, "Bohemian Rhapsody"); + var other = TestEntities.Track(owner, "Imagine"); + await SeedAsync( + owner, match, other); - [Fact] - public async Task Handle_GenreFilter_ReturnsOnlyTracksWithThatTag() - { - var owner = TestEntities.User(); - var rock = TestEntities.Track(owner, "Loud", tags: [Genre.Rock, Genre.Indie]); - var jazz = TestEntities.Track(owner, "Smooth", tags: [Genre.Jazz]); - await SeedAsync( - owner, rock, jazz); + var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + var item = Assert.Single(result.Items); + Assert.Equal("Bohemian Rhapsody", item.Title); + } - var result = await CreateHandler().Handle(new GetTracksQuery(null, 1, 10, Genre.Indie), TestContext.Current.CancellationToken); - var item = Assert.Single(result.Items); - Assert.Equal("Loud", item.Title); + [Fact] + public async Task Handle_GenreFilter_ReturnsOnlyTracksWithThatTag() + { + var owner = TestEntities.User(); + var rock = TestEntities.Track(owner, "Loud", tags: [Genre.Rock, Genre.Indie]); + var jazz = TestEntities.Track(owner, "Smooth", tags: [Genre.Jazz]); + await SeedAsync( + owner, rock, jazz); + + var result = await CreateHandler().Handle(new GetTracksQuery(null, 1, 10, Genre.Indie), TestContext.Current.CancellationToken); + var item = Assert.Single(result.Items); + Assert.Equal("Loud", item.Title); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs index 0db7d21..e24ee6a 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs @@ -5,109 +5,110 @@ using Musify.Application.Tracks; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Tracks; - -public sealed class RecordListeningProgressCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Tracks { - private RecordListeningProgressCommandHandler CreateHandler() => new(Database); - - private async Task ReloadAsync(Guid id) => - await Database.ListeningHistories.AsNoTracking() - .SingleAsync(l => l.Id == id, TestContext.Current.CancellationToken); - - private async Task<(User User, ListeningHistory Listen)> SeedListenAsync( - double durationSeconds, DateTime listenedAt) + public sealed class RecordListeningProgressCommandHandlerTests : HandlerTestBase { - var user = TestEntities.User(); - var track = TestEntities.Track(user, durationSeconds: durationSeconds); - var listen = TestEntities.ListeningHistory(user.Id, track.Id, listenedAt, isCounted: false); - await SeedAsync(user, track, listen); - return (user, listen); + private RecordListeningProgressCommandHandler CreateHandler() => new(Database); + + private async Task ReloadAsync(Guid id) => + await Database.ListeningHistories.AsNoTracking() + .SingleAsync(l => l.Id == id, TestContext.Current.CancellationToken); + + private async Task<(User User, ListeningHistory Listen)> SeedListenAsync( + double durationSeconds, DateTime listenedAt) + { + var user = TestEntities.User(); + var track = TestEntities.Track(user, durationSeconds: durationSeconds); + var listen = TestEntities.ListeningHistory(user.Id, track.Id, listenedAt, isCounted: false); + await SeedAsync(user, track, listen); + return (user, listen); + } + + [Fact] + public async Task Handle_BelowThreshold_StoresSecondsWithoutCounting() + { + var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); + + var result = await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var stored = await ReloadAsync(listen.Id); + Assert.Equal(10, stored.PlayedSeconds); + Assert.False(stored.IsCounted); + } + + [Fact] + public async Task Handle_ReachesThirtySeconds_MarksAsCounted() + { + var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); + + await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 30), TestContext.Current.CancellationToken); + + Assert.True((await ReloadAsync(listen.Id)).IsCounted); + } + + [Fact] + public async Task Handle_ShortTrack_CountsAtHalfDuration() + { + var (user, listen) = await SeedListenAsync(20, DateTime.UtcNow.AddSeconds(-60)); + + await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken); + + Assert.True((await ReloadAsync(listen.Id)).IsCounted); + } + + [Fact] + public async Task Handle_LowerValueThanStored_KeepsHighest() + { + var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); + var handler = CreateHandler(); + + await handler.Handle(new RecordListeningProgressCommand(user.Id, listen.Id, 40), TestContext.Current.CancellationToken); + await handler.Handle(new RecordListeningProgressCommand(user.Id, listen.Id, 15), TestContext.Current.CancellationToken); + + var stored = await ReloadAsync(listen.Id); + Assert.Equal(40, stored.PlayedSeconds); + Assert.True(stored.IsCounted); + } + + [Fact] + public async Task Handle_MoreThanElapsedClock_IsCappedToElapsedTime() + { + var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-20)); + + await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 150), TestContext.Current.CancellationToken); + + var stored = await ReloadAsync(listen.Id); + Assert.InRange(stored.PlayedSeconds!.Value, 24, 27); + } + + [Fact] + public async Task Handle_MoreThanTrackDuration_IsCappedToDuration() + { + var (user, listen) = await SeedListenAsync(50, DateTime.UtcNow.AddMinutes(-30)); + + await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 900), TestContext.Current.CancellationToken); + + Assert.Equal(52, (await ReloadAsync(listen.Id)).PlayedSeconds); + } + + [Fact] + public async Task Handle_OtherUsersListen_ReturnsNotFound() + { + var (_, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); + + var result = await CreateHandler().Handle( + new RecordListeningProgressCommand(long.MaxValue, listen.Id, 10), TestContext.Current.CancellationToken); + + Assert.True(result.IsError); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } - - [Fact] - public async Task Handle_BelowThreshold_StoresSecondsWithoutCounting() - { - var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); - - var result = await CreateHandler().Handle( - new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var stored = await ReloadAsync(listen.Id); - Assert.Equal(10, stored.PlayedSeconds); - Assert.False(stored.IsCounted); - } - - [Fact] - public async Task Handle_ReachesThirtySeconds_MarksAsCounted() - { - var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); - - await CreateHandler().Handle( - new RecordListeningProgressCommand(user.Id, listen.Id, 30), TestContext.Current.CancellationToken); - - Assert.True((await ReloadAsync(listen.Id)).IsCounted); - } - - [Fact] - public async Task Handle_ShortTrack_CountsAtHalfDuration() - { - var (user, listen) = await SeedListenAsync(20, DateTime.UtcNow.AddSeconds(-60)); - - await CreateHandler().Handle( - new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken); - - Assert.True((await ReloadAsync(listen.Id)).IsCounted); - } - - [Fact] - public async Task Handle_LowerValueThanStored_KeepsHighest() - { - var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); - var handler = CreateHandler(); - - await handler.Handle(new RecordListeningProgressCommand(user.Id, listen.Id, 40), TestContext.Current.CancellationToken); - await handler.Handle(new RecordListeningProgressCommand(user.Id, listen.Id, 15), TestContext.Current.CancellationToken); - - var stored = await ReloadAsync(listen.Id); - Assert.Equal(40, stored.PlayedSeconds); - Assert.True(stored.IsCounted); - } - - [Fact] - public async Task Handle_MoreThanElapsedClock_IsCappedToElapsedTime() - { - var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-20)); - - await CreateHandler().Handle( - new RecordListeningProgressCommand(user.Id, listen.Id, 150), TestContext.Current.CancellationToken); - - var stored = await ReloadAsync(listen.Id); - Assert.InRange(stored.PlayedSeconds!.Value, 24, 27); - } - - [Fact] - public async Task Handle_MoreThanTrackDuration_IsCappedToDuration() - { - var (user, listen) = await SeedListenAsync(50, DateTime.UtcNow.AddMinutes(-30)); - - await CreateHandler().Handle( - new RecordListeningProgressCommand(user.Id, listen.Id, 900), TestContext.Current.CancellationToken); - - Assert.Equal(52, (await ReloadAsync(listen.Id)).PlayedSeconds); - } - - [Fact] - public async Task Handle_OtherUsersListen_ReturnsNotFound() - { - var (_, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); - - var result = await CreateHandler().Handle( - new RecordListeningProgressCommand(long.MaxValue, listen.Id, 10), TestContext.Current.CancellationToken); - - Assert.True(result.IsError); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } - } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs index 33376ef..31aed91 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs @@ -9,77 +9,78 @@ 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(), Arg.Any()) - .Returns("https://storage.musify.test/presigned-upload"); + private readonly IStorageService storageService = Substitute.For(); - var config = uploadIntentConfig ?? TestConfigurations.UploadIntent(); + private RequestTrackUploadUrlsCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) + { + storageService + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://storage.musify.test/presigned-upload"); - return new RequestTrackUploadUrlsCommandHandler( - new UploadIntentIssuer( - Database, - storageService, - new UploadIntentValidator(Database, storageService, config), + var config = uploadIntentConfig ?? TestConfigurations.UploadIntent(); + + return new RequestTrackUploadUrlsCommandHandler( + new UploadIntentIssuer( + Database, + storageService, + new UploadIntentValidator(Database, storageService, config), + TestConfigurations.Storage(), + config, + NoOpLogger()), TestConfigurations.Storage(), - config, - NoOpLogger()), - TestConfigurations.Storage(), - TestConfigurations.Track(), - config); - } + TestConfigurations.Track(), + config); + } - [Fact] - public async Task Handle_UserExists_IssuesBothUploadUrlsAndCreatesIntents() - { - var user = TestEntities.User(); - await SeedAsync(user); + [Fact] + public async Task Handle_UserExists_IssuesBothUploadUrlsAndCreatesIntents() + { + var user = TestEntities.User(); + await SeedAsync(user); - var command = new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg"); + var command = new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg"); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - 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); + 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 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 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); + } - [Fact] - public async Task Handle_UserMissing_ReturnsNotFound() - { - var command = new RequestTrackUploadUrlsCommand(404, "webp", "image/webp", "mp3", "audio/mpeg"); + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var command = new RequestTrackUploadUrlsCommand(404, "webp", "image/webp", "mp3", "audio/mpeg"); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - [Fact] - public async Task Handle_UserAtActiveIntentLimit_ReturnsValidationErrorAndCreatesNothing() - { - var config = TestConfigurations.UploadIntent(); - config.MaxActiveUploadIntentsPerUser = 1; + [Fact] + public async Task Handle_UserAtActiveIntentLimit_ReturnsValidationErrorAndCreatesNothing() + { + var config = TestConfigurations.UploadIntent(); + config.MaxActiveUploadIntentsPerUser = 1; - var user = TestEntities.User(); - await SeedAsync(user, TestEntities.UploadIntent(user.Id)); + var user = TestEntities.User(); + await SeedAsync(user, TestEntities.UploadIntent(user.Id)); - var command = new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg"); + var command = new RequestTrackUploadUrlsCommand(user.Id, "webp", "image/webp", "mp3", "audio/mpeg"); - var result = await CreateHandler(config).Handle(command, 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)); + 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/Tracks/StartListeningCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/StartListeningCommandHandlerTests.cs index 87bc5eb..dd2f28f 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/StartListeningCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/StartListeningCommandHandlerTests.cs @@ -8,94 +8,95 @@ using Musify.Application.Tracks; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Tracks; - -public sealed class StartListeningCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Tracks { - private readonly IStreamTicketService ticketService = Substitute.For(); - - private StartListeningCommandHandler CreateHandler(PlaybackConfiguration? playback = null) + public sealed class StartListeningCommandHandlerTests : HandlerTestBase { - ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("ticket-token", 60)); + private readonly IStreamTicketService ticketService = Substitute.For(); - var issuer = new TrackStreamIssuer( - ticketService, - TestConfigurations.Track(), - TestConfigurations.StreamGateway("https://stream.musify.test"), - playback ?? TestConfigurations.Playback()); + private StartListeningCommandHandler CreateHandler(PlaybackConfiguration? playback = null) + { + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("ticket-token", 60)); - return new StartListeningCommandHandler(Database, issuer, playback ?? TestConfigurations.Playback(), NoOpLogger()); - } + var issuer = new TrackStreamIssuer( + ticketService, + TestConfigurations.Track(), + TestConfigurations.StreamGateway("https://stream.musify.test"), + playback ?? TestConfigurations.Playback()); - [Fact] - public async Task Handle_ProcessedTrack_IssuesTicketAndRecordsListen() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + return new StartListeningCommandHandler(Database, issuer, playback ?? TestConfigurations.Playback(), NoOpLogger()); + } - var result = await CreateHandler().Handle(new StartListeningCommand(track.Id, owner.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ProcessedTrack_IssuesTicketAndRecordsListen() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(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 StartListeningCommand(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.Equal(history.Id, result.Value.ListenId); - Assert.False(history.IsCounted); - Assert.Null(history.PlayedSeconds); - } + 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 StartListeningCommand(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(history.Id, result.Value.ListenId); + Assert.False(history.IsCounted); + Assert.Null(history.PlayedSeconds); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new StartListeningCommand(Guid.NewGuid(), 1), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AudioNotProcessedYet_ReturnsConflict() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio()); - await SeedAsync(owner, track); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new StartListeningCommand(track.Id, owner.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_AudioNotProcessedYet_ReturnsConflict() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio()); + await SeedAsync(owner, track); - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new StartListeningCommand(track.Id, owner.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AnonymousUser_AnonymousListeningDisabled_ReturnsUnauthorized() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + } - var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: false)); + [Fact] + public async Task Handle_AnonymousUser_AnonymousListeningDisabled_ReturnsUnauthorized() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); - var result = await handler.Handle(new StartListeningCommand(track.Id, UserId: null), TestContext.Current.CancellationToken); + var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: false)); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); - } + var result = await handler.Handle(new StartListeningCommand(track.Id, UserId: null), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AnonymousUser_AnonymousListeningEnabled_IssuesTicketWithoutRecordingHistory() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } - var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true)); + [Fact] + public async Task Handle_AnonymousUser_AnonymousListeningEnabled_IssuesTicketWithoutRecordingHistory() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); - var result = await handler.Handle(new StartListeningCommand(track.Id, UserId: null), TestContext.Current.CancellationToken); + var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true)); - Assert.False(result.IsError); - Assert.Equal("ticket-token", result.Value.Ticket); - Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); + var result = await handler.Handle(new StartListeningCommand(track.Id, UserId: null), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("ticket-token", result.Value.Ticket); + Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/UploadIntents/UploadIntentJobsCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/UploadIntents/UploadIntentJobsCommandHandlerTests.cs index 45f604b..389cbab 100644 --- a/backend/Tests/Musify.Application.Tests/UploadIntents/UploadIntentJobsCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/UploadIntents/UploadIntentJobsCommandHandlerTests.cs @@ -6,66 +6,67 @@ using Musify.Domain.ValueObjects; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.UploadIntents; - -public sealed class UploadIntentJobsCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.UploadIntents { - [Fact] - public async Task ExpireUploadIntents_OverdueIssuedIntent_IsMarkedExpired() + public sealed class UploadIntentJobsCommandHandlerTests : HandlerTestBase { - var owner = TestEntities.User(); - var overdue = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-5)); - var active = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(5)); - await SeedAsync(owner, overdue, active); - var handler = new ExpireUploadIntentsCommandHandler( - Database, TestConfigurations.UploadIntent(), NoOpLogger()); + [Fact] + public async Task ExpireUploadIntents_OverdueIssuedIntent_IsMarkedExpired() + { + var owner = TestEntities.User(); + var overdue = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-5)); + var active = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(5)); + await SeedAsync(owner, overdue, active); + var handler = new ExpireUploadIntentsCommandHandler( + Database, TestConfigurations.UploadIntent(), NoOpLogger()); - var changed = await handler.Handle(new ExpireUploadIntentsCommand(), TestContext.Current.CancellationToken); + var changed = await handler.Handle(new ExpireUploadIntentsCommand(), TestContext.Current.CancellationToken); - Assert.Equal(1, changed); - var stored = await Database.UploadIntents.AsNoTracking().ToListAsync(TestContext.Current.CancellationToken); - Assert.Equal(UploadIntentStatus.Expired, stored.Single(intent => intent.Id == overdue.Id).Status); - Assert.Equal(UploadIntentStatus.Issued, stored.Single(intent => intent.Id == active.Id).Status); - } + Assert.Equal(1, changed); + var stored = await Database.UploadIntents.AsNoTracking().ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal(UploadIntentStatus.Expired, stored.Single(intent => intent.Id == overdue.Id).Status); + Assert.Equal(UploadIntentStatus.Issued, stored.Single(intent => intent.Id == active.Id).Status); + } - [Fact] - public async Task ExpireUploadIntents_IntentExpiredLongAgo_IsDeleted() - { - var owner = TestEntities.User(); - var old = TestEntities.UploadIntent( - owner.Id, status: UploadIntentStatus.Expired, expiresAt: DateTime.UtcNow.AddDays(-30)); - await SeedAsync(owner, old); - var handler = new ExpireUploadIntentsCommandHandler( - Database, TestConfigurations.UploadIntent(), NoOpLogger()); + [Fact] + public async Task ExpireUploadIntents_IntentExpiredLongAgo_IsDeleted() + { + var owner = TestEntities.User(); + var old = TestEntities.UploadIntent( + owner.Id, status: UploadIntentStatus.Expired, expiresAt: DateTime.UtcNow.AddDays(-30)); + await SeedAsync(owner, old); + var handler = new ExpireUploadIntentsCommandHandler( + Database, TestConfigurations.UploadIntent(), NoOpLogger()); - await handler.Handle(new ExpireUploadIntentsCommand(), TestContext.Current.CancellationToken); + await handler.Handle(new ExpireUploadIntentsCommand(), TestContext.Current.CancellationToken); - Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); - } + Assert.Empty(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); + } - [Fact] - public async Task DeleteStaleTempUploads_RemovesOnlyObjectsOlderThanTheRetention() - { - var storage = Substitute.For(); - storage.ListObjectsAsync("test-bucket", "temp", Arg.Any()).Returns(Objects( - new StorageObject("temp/old", DateTime.UtcNow.AddDays(-10)), - new StorageObject("temp/new", DateTime.UtcNow))); - var handler = new DeleteStaleTempUploadsCommandHandler( - storage, TestConfigurations.Storage("test-bucket"), TestConfigurations.UploadIntent(), - NoOpLogger()); + [Fact] + public async Task DeleteStaleTempUploads_RemovesOnlyObjectsOlderThanTheRetention() + { + var storage = Substitute.For(); + storage.ListObjectsAsync("test-bucket", "temp", Arg.Any()).Returns(Objects( + new StorageObject("temp/old", DateTime.UtcNow.AddDays(-10)), + new StorageObject("temp/new", DateTime.UtcNow))); + var handler = new DeleteStaleTempUploadsCommandHandler( + storage, TestConfigurations.Storage("test-bucket"), TestConfigurations.UploadIntent(), + NoOpLogger()); - var deleted = await handler.Handle(new DeleteStaleTempUploadsCommand(), TestContext.Current.CancellationToken); + var deleted = await handler.Handle(new DeleteStaleTempUploadsCommand(), TestContext.Current.CancellationToken); - Assert.Equal(1, deleted); - await storage.Received(1).RemoveFileAsync("test-bucket", "temp/old", Arg.Any()); - await storage.DidNotReceive().RemoveFileAsync("test-bucket", "temp/new", Arg.Any()); - } + Assert.Equal(1, deleted); + await storage.Received(1).RemoveFileAsync("test-bucket", "temp/old", Arg.Any()); + await storage.DidNotReceive().RemoveFileAsync("test-bucket", "temp/new", Arg.Any()); + } - private static async IAsyncEnumerable Objects(params StorageObject[] objects) - { - foreach (var item in objects) - yield return item; + private static async IAsyncEnumerable Objects(params StorageObject[] objects) + { + foreach (var item in objects) + yield return item; - await Task.CompletedTask; + await Task.CompletedTask; + } } } diff --git a/backend/Tests/Musify.Application.Tests/Users/FollowUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/FollowUserCommandHandlerTests.cs index 3a17c25..3659bbf 100644 --- a/backend/Tests/Musify.Application.Tests/Users/FollowUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/FollowUserCommandHandlerTests.cs @@ -4,57 +4,58 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Users; using Xunit; -namespace Musify.Application.Tests.Users; - -public sealed class FollowUserCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Users { - private FollowUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_ValidTarget_CreatesFollow() + public sealed class FollowUserCommandHandlerTests : HandlerTestBase { - var follower = TestEntities.User(1, "follower"); - var followed = TestEntities.User(2, "followed"); - await SeedAsync(follower, followed); + private FollowUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); - var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ValidTarget_CreatesFollow() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed); - Assert.False(result.IsError); - Assert.Single(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken)); - } + var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AlreadyFollowing_IsIdempotent() - { - var follower = TestEntities.User(1, "follower"); - var followed = TestEntities.User(2, "followed"); - await SeedAsync(follower, followed, new Musify.Domain.Entities.UserFollow { FollowerId = follower.Id, FollowedId = followed.Id }); + Assert.False(result.IsError); + Assert.Single(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken)); + } - var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_AlreadyFollowing_IsIdempotent() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed, new Musify.Domain.Entities.UserFollow { FollowerId = follower.Id, FollowedId = followed.Id }); - Assert.False(result.IsError); - Assert.Single(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken)); - } + var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_SelfFollow_ReturnsValidationError() - { - var user = TestEntities.User(); - await SeedAsync(user); + Assert.False(result.IsError); + Assert.Single(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken)); + } - var result = await CreateHandler().Handle(new FollowUserCommand(user.Id, user.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_SelfFollow_ReturnsValidationError() + { + var user = TestEntities.User(); + await SeedAsync(user); - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new FollowUserCommand(user.Id, user.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_TargetMissing_ReturnsNotFound() - { - var follower = TestEntities.User(); - await SeedAsync(follower); + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, 404), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TargetMissing_ReturnsNotFound() + { + var follower = TestEntities.User(); + await SeedAsync(follower); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, 404), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs index 77a3850..55910f1 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs @@ -2,130 +2,131 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Users; using Xunit; -namespace Musify.Application.Tests.Users; - -public sealed class GetListeningStatsQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Users { - private GetListeningStatsQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_NoHistory_ReturnsZeroes() + public sealed class GetListeningStatsQueryHandlerTests : HandlerTestBase { - var user = TestEntities.User(); - await SeedAsync(user); + private GetListeningStatsQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NoHistory_ReturnsZeroes() + { + var user = TestEntities.User(); + await SeedAsync(user); - Assert.Equal(0, result.TracksThisWeek); - Assert.Equal(0, result.SecondsThisWeek); - Assert.Equal(0, result.StreakDays); - } + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_ListensThisWeek_CountsDistinctTracksAndTotalSeconds() - { - var user = TestEntities.User(); - var trackA = TestEntities.Track(user, "Track A", durationSeconds: 200); - var trackB = TestEntities.Track(user, "Track B", durationSeconds: 100); - await SeedAsync( - user, trackA, trackB, - TestEntities.ListeningHistory(user.Id, trackA.Id, DateTime.UtcNow.AddDays(-1)), - TestEntities.ListeningHistory(user.Id, trackA.Id, DateTime.UtcNow.AddHours(-1)), - TestEntities.ListeningHistory(user.Id, trackB.Id, DateTime.UtcNow), - TestEntities.ListeningHistory(user.Id, trackB.Id, DateTime.UtcNow.AddDays(-10))); + Assert.Equal(0, result.TracksThisWeek); + Assert.Equal(0, result.SecondsThisWeek); + Assert.Equal(0, result.StreakDays); + } - var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ListensThisWeek_CountsDistinctTracksAndTotalSeconds() + { + var user = TestEntities.User(); + var trackA = TestEntities.Track(user, "Track A", durationSeconds: 200); + var trackB = TestEntities.Track(user, "Track B", durationSeconds: 100); + await SeedAsync( + user, trackA, trackB, + TestEntities.ListeningHistory(user.Id, trackA.Id, DateTime.UtcNow.AddDays(-1)), + TestEntities.ListeningHistory(user.Id, trackA.Id, DateTime.UtcNow.AddHours(-1)), + TestEntities.ListeningHistory(user.Id, trackB.Id, DateTime.UtcNow), + TestEntities.ListeningHistory(user.Id, trackB.Id, DateTime.UtcNow.AddDays(-10))); - Assert.Equal(2, result.TracksThisWeek); - Assert.Equal(500, result.SecondsThisWeek); - } + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PlayedSeconds_SumsPlayedTimeInsteadOfDuration() - { - var user = TestEntities.User(); - var track = TestEntities.Track(user, durationSeconds: 200); - await SeedAsync( - user, track, - TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 200), - TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 12, isCounted: false)); + Assert.Equal(2, result.TracksThisWeek); + Assert.Equal(500, result.SecondsThisWeek); + } - var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_PlayedSeconds_SumsPlayedTimeInsteadOfDuration() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user, durationSeconds: 200); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 200), + TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 12, isCounted: false)); - Assert.Equal(212, result.SecondsThisWeek); - } + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_UncountedListens_AreExcludedFromTracksAndStreak() - { - var user = TestEntities.User(); - var track = TestEntities.Track(user); - await SeedAsync( - user, track, - TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 3, isCounted: false)); + Assert.Equal(212, result.SecondsThisWeek); + } - var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_UncountedListens_AreExcludedFromTracksAndStreak() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 3, isCounted: false)); - Assert.Equal(0, result.TracksThisWeek); - Assert.Equal(0, result.StreakDays); - Assert.Equal(3, result.SecondsThisWeek); - } + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_ListenedTodayAndYesterday_StreakIsTwo() - { - var user = TestEntities.User(); - var track = TestEntities.Track(user); - await SeedAsync( - user, track, - TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow), - TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-1))); + Assert.Equal(0, result.TracksThisWeek); + Assert.Equal(0, result.StreakDays); + Assert.Equal(3, result.SecondsThisWeek); + } - var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ListenedTodayAndYesterday_StreakIsTwo() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow), + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-1))); - Assert.Equal(2, result.StreakDays); - } + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_GapBeforeToday_StopsStreakAtGap() - { - var user = TestEntities.User(); - var track = TestEntities.Track(user); - await SeedAsync( - user, track, - TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow), - TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-2))); + Assert.Equal(2, result.StreakDays); + } - var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_GapBeforeToday_StopsStreakAtGap() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow), + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-2))); - Assert.Equal(1, result.StreakDays); - } + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_ListenedYesterdayButNotToday_StreakIsStillAlive() - { - var user = TestEntities.User(); - var track = TestEntities.Track(user); - await SeedAsync( - user, track, - TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-1))); + Assert.Equal(1, result.StreakDays); + } - var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ListenedYesterdayButNotToday_StreakIsStillAlive() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-1))); - Assert.Equal(1, result.StreakDays); - } + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_NoListenTodayOrYesterday_StreakIsZero() - { - var user = TestEntities.User(); - var track = TestEntities.Track(user); - await SeedAsync( - user, track, - TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-2))); + Assert.Equal(1, result.StreakDays); + } - var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NoListenTodayOrYesterday_StreakIsZero() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-2))); - Assert.Equal(0, result.StreakDays); + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + + Assert.Equal(0, result.StreakDays); + } } } 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/GetUserFollowersQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUserFollowersQueryHandlerTests.cs index 4778b07..825e7ff 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetUserFollowersQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetUserFollowersQueryHandlerTests.cs @@ -6,89 +6,90 @@ using Musify.Application.Users.Responses; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Users; - -public sealed class GetUserFollowersQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Users { - private GetUserFollowersQueryHandler CreateHandler() => new(Database); - - private async Task>> Send(long? viewerId, long userId) => - await CreateHandler().Handle(new GetUserFollowersQuery(viewerId, userId, 1, 20), TestContext.Current.CancellationToken); - - [Fact] - public async Task Handle_OwnProfile_ReturnsFollowers() + public sealed class GetUserFollowersQueryHandlerTests : HandlerTestBase { - var user = TestEntities.User(1, "user"); - var follower = TestEntities.User(2, "follower"); - await SeedAsync(user, follower, new UserFollow { FollowerId = follower.Id, FollowedId = user.Id }); + private GetUserFollowersQueryHandler CreateHandler() => new(Database); - var result = await Send(user.Id, user.Id); + private async Task>> Send(long? viewerId, long userId) => + await CreateHandler().Handle(new GetUserFollowersQuery(viewerId, userId, 1, 20), TestContext.Current.CancellationToken); - Assert.False(result.IsError); - var item = Assert.Single(result.Value.Items); - Assert.Equal(follower.Id, item.Id); - Assert.False(item.IsFollowedByViewer); - } + [Fact] + public async Task Handle_OwnProfile_ReturnsFollowers() + { + var user = TestEntities.User(1, "user"); + var follower = TestEntities.User(2, "follower"); + await SeedAsync(user, follower, new UserFollow { FollowerId = follower.Id, FollowedId = user.Id }); - [Fact] - public async Task Handle_MutualFollow_ReturnsFollowersWithViewerFollowState() - { - var viewer = TestEntities.User(1, "viewer"); - var user = TestEntities.User(2, "user"); - var other = TestEntities.User(3, "other"); - await SeedAsync( - viewer, user, other, - new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id }, - new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id }, - new UserFollow { FollowerId = other.Id, FollowedId = user.Id }, - new UserFollow { FollowerId = viewer.Id, FollowedId = other.Id }); + var result = await Send(user.Id, user.Id); - var result = await Send(viewer.Id, user.Id); + Assert.False(result.IsError); + var item = Assert.Single(result.Value.Items); + Assert.Equal(follower.Id, item.Id); + Assert.False(item.IsFollowedByViewer); + } - Assert.False(result.IsError); - Assert.Equal(2, result.Value.TotalItemCount); - Assert.All(result.Value.Items, item => Assert.Equal(item.Id == other.Id, item.IsFollowedByViewer)); - } + [Fact] + public async Task Handle_MutualFollow_ReturnsFollowersWithViewerFollowState() + { + var viewer = TestEntities.User(1, "viewer"); + var user = TestEntities.User(2, "user"); + var other = TestEntities.User(3, "other"); + await SeedAsync( + viewer, user, other, + new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id }, + new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id }, + new UserFollow { FollowerId = other.Id, FollowedId = user.Id }, + new UserFollow { FollowerId = viewer.Id, FollowedId = other.Id }); - [Fact] - public async Task Handle_ViewerFollowsButNotFollowedBack_ReturnsForbidden() - { - var viewer = TestEntities.User(1, "viewer"); - var user = TestEntities.User(2, "user"); - await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id }); + var result = await Send(viewer.Id, user.Id); - var result = await Send(viewer.Id, user.Id); + Assert.False(result.IsError); + Assert.Equal(2, result.Value.TotalItemCount); + Assert.All(result.Value.Items, item => Assert.Equal(item.Id == other.Id, item.IsFollowedByViewer)); + } - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } + [Fact] + public async Task Handle_ViewerFollowsButNotFollowedBack_ReturnsForbidden() + { + var viewer = TestEntities.User(1, "viewer"); + var user = TestEntities.User(2, "user"); + await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id }); - [Fact] - public async Task Handle_FollowedByUserButNotFollowingBack_ReturnsForbidden() - { - var viewer = TestEntities.User(1, "viewer"); - var user = TestEntities.User(2, "user"); - await SeedAsync(viewer, user, new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id }); + var result = await Send(viewer.Id, user.Id); - var result = await Send(viewer.Id, user.Id); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } + [Fact] + public async Task Handle_FollowedByUserButNotFollowingBack_ReturnsForbidden() + { + var viewer = TestEntities.User(1, "viewer"); + var user = TestEntities.User(2, "user"); + await SeedAsync(viewer, user, new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id }); - [Fact] - public async Task Handle_Anonymous_ReturnsForbidden() - { - await SeedAsync(TestEntities.User(1, "user")); + var result = await Send(viewer.Id, user.Id); - var result = await Send(null, 1); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } + [Fact] + public async Task Handle_Anonymous_ReturnsForbidden() + { + await SeedAsync(TestEntities.User(1, "user")); - [Fact] - public async Task Handle_UserMissing_ReturnsNotFound() - { - var result = await Send(1, 404); + var result = await Send(null, 1); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } + + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var result = await Send(1, 404); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUserFollowingQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUserFollowingQueryHandlerTests.cs index 580ebd5..c074d00 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetUserFollowingQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetUserFollowingQueryHandlerTests.cs @@ -4,71 +4,72 @@ using Musify.Application.Users; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Users; - -public sealed class GetUserFollowingQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Users { - private GetUserFollowingQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_Anonymous_ReturnsFollowedUsers() + public sealed class GetUserFollowingQueryHandlerTests : HandlerTestBase { - var user = TestEntities.User(1, "user"); - var followedA = TestEntities.User(2, "followedA"); - var followedB = TestEntities.User(3, "followedB"); - await SeedAsync( - user, followedA, followedB, - new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id }, - new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id }); + private GetUserFollowingQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, user.Id, 1, 20), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_Anonymous_ReturnsFollowedUsers() + { + var user = TestEntities.User(1, "user"); + var followedA = TestEntities.User(2, "followedA"); + var followedB = TestEntities.User(3, "followedB"); + await SeedAsync( + user, followedA, followedB, + new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id }, + new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id }); - Assert.False(result.IsError); - Assert.Equal(2, result.Value.TotalItemCount); - Assert.All(result.Value.Items, item => Assert.False(item.IsFollowedByViewer)); - } + var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, user.Id, 1, 20), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_Viewer_MarksUsersTheViewerFollows() - { - var viewer = TestEntities.User(1, "viewer"); - var user = TestEntities.User(2, "user"); - var followedA = TestEntities.User(3, "followedA"); - var followedB = TestEntities.User(4, "followedB"); - await SeedAsync( - viewer, user, followedA, followedB, - new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id }, - new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id }, - new UserFollow { FollowerId = viewer.Id, FollowedId = followedA.Id }); + Assert.False(result.IsError); + Assert.Equal(2, result.Value.TotalItemCount); + Assert.All(result.Value.Items, item => Assert.False(item.IsFollowedByViewer)); + } - var result = await CreateHandler().Handle(new GetUserFollowingQuery(viewer.Id, user.Id, 1, 20), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_Viewer_MarksUsersTheViewerFollows() + { + var viewer = TestEntities.User(1, "viewer"); + var user = TestEntities.User(2, "user"); + var followedA = TestEntities.User(3, "followedA"); + var followedB = TestEntities.User(4, "followedB"); + await SeedAsync( + viewer, user, followedA, followedB, + new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id }, + new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id }, + new UserFollow { FollowerId = viewer.Id, FollowedId = followedA.Id }); - Assert.False(result.IsError); - Assert.All(result.Value.Items, item => Assert.Equal(item.Id == followedA.Id, item.IsFollowedByViewer)); - } + var result = await CreateHandler().Handle(new GetUserFollowingQuery(viewer.Id, user.Id, 1, 20), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_Pagination_ReturnsRequestedPage() - { - await SeedAsync( - TestEntities.User(1, "user"), TestEntities.User(2, "a"), TestEntities.User(3, "b"), TestEntities.User(4, "c"), - new UserFollow { FollowerId = 1, FollowedId = 2 }, - new UserFollow { FollowerId = 1, FollowedId = 3 }, - new UserFollow { FollowerId = 1, FollowedId = 4 }); + Assert.False(result.IsError); + Assert.All(result.Value.Items, item => Assert.Equal(item.Id == followedA.Id, item.IsFollowedByViewer)); + } - var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 1, 2, 2), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_Pagination_ReturnsRequestedPage() + { + await SeedAsync( + TestEntities.User(1, "user"), TestEntities.User(2, "a"), TestEntities.User(3, "b"), TestEntities.User(4, "c"), + new UserFollow { FollowerId = 1, FollowedId = 2 }, + new UserFollow { FollowerId = 1, FollowedId = 3 }, + new UserFollow { FollowerId = 1, FollowedId = 4 }); - Assert.False(result.IsError); - Assert.Single(result.Value.Items); - Assert.Equal(3, result.Value.TotalItemCount); - Assert.False(result.Value.HasNextPage); - } + var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 1, 2, 2), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_UserMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 404, 1, 20), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Single(result.Value.Items); + Assert.Equal(3, result.Value.TotalItemCount); + Assert.False(result.Value.HasNextPage); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 404, 1, 20), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUserProfileQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUserProfileQueryHandlerTests.cs index e7e40d8..45665aa 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetUserProfileQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetUserProfileQueryHandlerTests.cs @@ -4,77 +4,78 @@ using Musify.Application.Users; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Users; - -public sealed class GetUserProfileQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Users { - private GetUserProfileQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_ExistingUser_ReturnsFollowerAndFollowingCounts() + public sealed class GetUserProfileQueryHandlerTests : HandlerTestBase { - var user = TestEntities.User(1, "user"); - var followerA = TestEntities.User(2, "followerA"); - var followerB = TestEntities.User(3, "followerB"); - var followed = TestEntities.User(4, "followed"); - await SeedAsync( - user, followerA, followerB, followed, - new UserFollow { FollowerId = followerA.Id, FollowedId = user.Id }, - new UserFollow { FollowerId = followerB.Id, FollowedId = user.Id }, - new UserFollow { FollowerId = user.Id, FollowedId = followed.Id }); + private GetUserProfileQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ExistingUser_ReturnsFollowerAndFollowingCounts() + { + var user = TestEntities.User(1, "user"); + var followerA = TestEntities.User(2, "followerA"); + var followerB = TestEntities.User(3, "followerB"); + var followed = TestEntities.User(4, "followed"); + await SeedAsync( + user, followerA, followerB, followed, + new UserFollow { FollowerId = followerA.Id, FollowedId = user.Id }, + new UserFollow { FollowerId = followerB.Id, FollowedId = user.Id }, + new UserFollow { FollowerId = user.Id, FollowedId = followed.Id }); - Assert.False(result.IsError); - Assert.Equal(2, result.Value.FollowersCount); - Assert.Equal(1, result.Value.FollowingCount); - } + var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_MutualFollow_ViewerCanSeeFollowersAndIsFollowing() - { - var viewer = TestEntities.User(1, "viewer"); - var user = TestEntities.User(2, "user"); - await SeedAsync( - viewer, user, - new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id }, - new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id }); + Assert.False(result.IsError); + Assert.Equal(2, result.Value.FollowersCount); + Assert.Equal(1, result.Value.FollowingCount); + } - var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_MutualFollow_ViewerCanSeeFollowersAndIsFollowing() + { + var viewer = TestEntities.User(1, "viewer"); + var user = TestEntities.User(2, "user"); + await SeedAsync( + viewer, user, + new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id }, + new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id }); - Assert.True(result.Value.IsFollowing); - Assert.True(result.Value.CanViewFollowers); - } + var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_OneWayFollow_ViewerCannotSeeFollowers() - { - var viewer = TestEntities.User(1, "viewer"); - var user = TestEntities.User(2, "user"); - await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id }); + Assert.True(result.Value.IsFollowing); + Assert.True(result.Value.CanViewFollowers); + } - var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_OneWayFollow_ViewerCannotSeeFollowers() + { + var viewer = TestEntities.User(1, "viewer"); + var user = TestEntities.User(2, "user"); + await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id }); - Assert.True(result.Value.IsFollowing); - Assert.False(result.Value.CanViewFollowers); - } + var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_OwnProfile_CanSeeFollowersButIsNotFollowing() - { - await SeedAsync(TestEntities.User(1, "user")); + Assert.True(result.Value.IsFollowing); + Assert.False(result.Value.CanViewFollowers); + } - var result = await CreateHandler().Handle(new GetUserProfileQuery(1, 1), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_OwnProfile_CanSeeFollowersButIsNotFollowing() + { + await SeedAsync(TestEntities.User(1, "user")); - Assert.False(result.Value.IsFollowing); - Assert.True(result.Value.CanViewFollowers); - } + var result = await CreateHandler().Handle(new GetUserProfileQuery(1, 1), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_UserMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetUserProfileQuery(404), TestContext.Current.CancellationToken); + Assert.False(result.Value.IsFollowing); + Assert.True(result.Value.CanViewFollowers); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetUserProfileQuery(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 9734a7b..20a5f2e 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs @@ -3,39 +3,40 @@ using Musify.Application.Users; using Musify.Domain.Entities; 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); - Assert.Equal(2, result.TotalItemCount); - } + [Fact] + public async Task Handle_NoSearch_ReturnsEveryUser() + { + await SeedAsync(TestEntities.User(1, "alice"), TestEntities.User(2, "bob")); - [Fact] - public async Task Handle_UsernameSearch_ReturnsOnlyMatches() - { - await SeedAsync(TestEntities.User(1, "alice-wonderland"), TestEntities.User(2, "bob-builder")); + var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null), TestContext.Current.CancellationToken); + Assert.Equal(2, result.TotalItemCount); + } - var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: "alice"), TestContext.Current.CancellationToken); - var user = Assert.Single(result.Items); - Assert.Equal(1, user.Id); - } + [Fact] + public async Task Handle_UsernameSearch_ReturnsOnlyMatches() + { + await SeedAsync(TestEntities.User(1, "alice-wonderland"), TestEntities.User(2, "bob-builder")); - [Fact] - public async Task Handle_Viewer_MarksFollowedUsers() - { - await SeedAsync( - TestEntities.User(1, "viewer"), TestEntities.User(2, "alice"), TestEntities.User(3, "bob"), - new UserFollow { FollowerId = 1, FollowedId = 2 }); + var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: "alice"), TestContext.Current.CancellationToken); + var user = Assert.Single(result.Items); + Assert.Equal(1, user.Id); + } - var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null, ViewerId: 1), TestContext.Current.CancellationToken); - Assert.All(result.Items, item => Assert.Equal(item.Id == 2, item.IsFollowedByViewer)); + [Fact] + public async Task Handle_Viewer_MarksFollowedUsers() + { + await SeedAsync( + TestEntities.User(1, "viewer"), TestEntities.User(2, "alice"), TestEntities.User(3, "bob"), + new UserFollow { FollowerId = 1, FollowedId = 2 }); + + var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null, ViewerId: 1), TestContext.Current.CancellationToken); + Assert.All(result.Items, item => Assert.Equal(item.Id == 2, item.IsFollowedByViewer)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Users/IsFollowingUserQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/IsFollowingUserQueryHandlerTests.cs index 48650e0..2d7f9ea 100644 --- a/backend/Tests/Musify.Application.Tests/Users/IsFollowingUserQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/IsFollowingUserQueryHandlerTests.cs @@ -3,33 +3,34 @@ using Musify.Application.Users; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Users; - -public sealed class IsFollowingUserQueryHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Users { - private IsFollowingUserQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_Following_ReturnsTrue() + public sealed class IsFollowingUserQueryHandlerTests : HandlerTestBase { - var follower = TestEntities.User(1, "follower"); - var followed = TestEntities.User(2, "followed"); - await SeedAsync(follower, followed, new UserFollow { FollowerId = follower.Id, FollowedId = followed.Id }); + private IsFollowingUserQueryHandler CreateHandler() => new(Database); - var result = await CreateHandler().Handle(new IsFollowingUserQuery(follower.Id, followed.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_Following_ReturnsTrue() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed, new UserFollow { FollowerId = follower.Id, FollowedId = followed.Id }); - Assert.True(result); - } + var result = await CreateHandler().Handle(new IsFollowingUserQuery(follower.Id, followed.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_NotFollowing_ReturnsFalse() - { - var follower = TestEntities.User(1, "follower"); - var followed = TestEntities.User(2, "followed"); - await SeedAsync(follower, followed); + Assert.True(result); + } - var result = await CreateHandler().Handle(new IsFollowingUserQuery(follower.Id, followed.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NotFollowing_ReturnsFalse() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed); - Assert.False(result); + var result = await CreateHandler().Handle(new IsFollowingUserQuery(follower.Id, followed.Id), TestContext.Current.CancellationToken); + + Assert.False(result); + } } } 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/Users/UnfollowUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/UnfollowUserCommandHandlerTests.cs index 7d24067..e853dd8 100644 --- a/backend/Tests/Musify.Application.Tests/Users/UnfollowUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/UnfollowUserCommandHandlerTests.cs @@ -4,34 +4,35 @@ using Musify.Application.Users; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Users; - -public sealed class UnfollowUserCommandHandlerTests : HandlerTestBase +namespace Musify.Application.Tests.Users { - private UnfollowUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_ExistingFollow_RemovesIt() + public sealed class UnfollowUserCommandHandlerTests : HandlerTestBase { - var follower = TestEntities.User(1, "follower"); - var followed = TestEntities.User(2, "followed"); - await SeedAsync(follower, followed, new UserFollow { FollowerId = follower.Id, FollowedId = followed.Id }); + private UnfollowUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); - var result = await CreateHandler().Handle(new UnfollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ExistingFollow_RemovesIt() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed, new UserFollow { FollowerId = follower.Id, FollowedId = followed.Id }); - Assert.False(result.IsError); - Assert.Empty(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken)); - } + var result = await CreateHandler().Handle(new UnfollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_NotFollowing_IsIdempotent() - { - var follower = TestEntities.User(1, "follower"); - var followed = TestEntities.User(2, "followed"); - await SeedAsync(follower, followed); + Assert.False(result.IsError); + Assert.Empty(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken)); + } - var result = await CreateHandler().Handle(new UnfollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NotFollowing_IsIdempotent() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed); - Assert.False(result.IsError); + var result = await CreateHandler().Handle(new UnfollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + } } } diff --git a/backend/Tests/Musify.Domain.Tests/ValueObjects/GenreCompatibilityTests.cs b/backend/Tests/Musify.Domain.Tests/ValueObjects/GenreCompatibilityTests.cs index 56784fc..1aa3589 100644 --- a/backend/Tests/Musify.Domain.Tests/ValueObjects/GenreCompatibilityTests.cs +++ b/backend/Tests/Musify.Domain.Tests/ValueObjects/GenreCompatibilityTests.cs @@ -1,50 +1,51 @@ using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Domain.Tests.ValueObjects; - -public sealed class GenreCompatibilityTests +namespace Musify.Domain.Tests.ValueObjects { - [Theory] - [InlineData(Genre.Classical, Genre.Metal)] - [InlineData(Genre.Classical, Genre.Punk)] - [InlineData(Genre.Ambient, Genre.Dubstep)] - [InlineData(Genre.Lofi, Genre.DrumAndBass)] - public void AreCompatible_IncompatiblePair_ReturnsFalse(Genre first, Genre second) + public sealed class GenreCompatibilityTests { - Assert.False(GenreCompatibility.AreCompatible(first, second)); - Assert.False(GenreCompatibility.AreCompatible(second, first)); - } + [Theory] + [InlineData(Genre.Classical, Genre.Metal)] + [InlineData(Genre.Classical, Genre.Punk)] + [InlineData(Genre.Ambient, Genre.Dubstep)] + [InlineData(Genre.Lofi, Genre.DrumAndBass)] + public void AreCompatible_IncompatiblePair_ReturnsFalse(Genre first, Genre second) + { + Assert.False(GenreCompatibility.AreCompatible(first, second)); + Assert.False(GenreCompatibility.AreCompatible(second, first)); + } - [Theory] - [InlineData(Genre.Pop, Genre.Rock)] - [InlineData(Genre.HipHop, Genre.RnB)] - [InlineData(Genre.Classical, Genre.Jazz)] - public void AreCompatible_CompatiblePair_ReturnsTrue(Genre first, Genre second) - { - Assert.True(GenreCompatibility.AreCompatible(first, second)); - } + [Theory] + [InlineData(Genre.Pop, Genre.Rock)] + [InlineData(Genre.HipHop, Genre.RnB)] + [InlineData(Genre.Classical, Genre.Jazz)] + public void AreCompatible_CompatiblePair_ReturnsTrue(Genre first, Genre second) + { + Assert.True(GenreCompatibility.AreCompatible(first, second)); + } - [Fact] - public void AreCompatible_SameGenre_ReturnsTrue() - { - Assert.True(GenreCompatibility.AreCompatible(Genre.Pop, Genre.Pop)); - } + [Fact] + public void AreCompatible_SameGenre_ReturnsTrue() + { + Assert.True(GenreCompatibility.AreCompatible(Genre.Pop, Genre.Pop)); + } - [Fact] - public void FindConflicts_NoIncompatiblePairs_ReturnsEmpty() - { - var conflicts = GenreCompatibility.FindConflicts([Genre.Pop, Genre.Rock, Genre.Indie]); + [Fact] + public void FindConflicts_NoIncompatiblePairs_ReturnsEmpty() + { + var conflicts = GenreCompatibility.FindConflicts([Genre.Pop, Genre.Rock, Genre.Indie]); - Assert.Empty(conflicts); - } + Assert.Empty(conflicts); + } - [Fact] - public void FindConflicts_IncompatiblePairPresent_ReturnsIt() - { - var conflicts = GenreCompatibility.FindConflicts([Genre.Pop, Genre.Classical, Genre.Metal]); + [Fact] + public void FindConflicts_IncompatiblePairPresent_ReturnsIt() + { + var conflicts = GenreCompatibility.FindConflicts([Genre.Pop, Genre.Classical, Genre.Metal]); - var conflict = Assert.Single(conflicts); - Assert.Equal((Genre.Classical, Genre.Metal), conflict); + var conflict = Assert.Single(conflicts); + Assert.Equal((Genre.Classical, Genre.Metal), conflict); + } } } 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/Jobs/RecurringJobsRegistrarTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Jobs/RecurringJobsRegistrarTests.cs index 248e062..a9ac622 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Jobs/RecurringJobsRegistrarTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Jobs/RecurringJobsRegistrarTests.cs @@ -1,22 +1,23 @@ using Musify.Infrastructure.Jobs; using Xunit; -namespace Musify.Infrastructure.Tests.Jobs; - -public sealed class RecurringJobsRegistrarTests +namespace Musify.Infrastructure.Tests.Jobs { - [Theory] - [InlineData(1, "*/1 * * * *")] - [InlineData(59, "*/1 * * * *")] - [InlineData(60, "*/1 * * * *")] - [InlineData(61, "*/2 * * * *")] - [InlineData(300, "*/5 * * * *")] - [InlineData(3599, "0 */1 * * *")] - [InlineData(3600, "0 */1 * * *")] - [InlineData(7200, "0 */2 * * *")] - [InlineData(0, "*/1 * * * *")] - public void EveryCron_RoundsUpToWholeMinutes(int seconds, string expected) + public sealed class RecurringJobsRegistrarTests { - Assert.Equal(expected, RecurringJobsRegistrar.EveryCron(seconds)); + [Theory] + [InlineData(1, "*/1 * * * *")] + [InlineData(59, "*/1 * * * *")] + [InlineData(60, "*/1 * * * *")] + [InlineData(61, "*/2 * * * *")] + [InlineData(300, "*/5 * * * *")] + [InlineData(3599, "0 */1 * * *")] + [InlineData(3600, "0 */1 * * *")] + [InlineData(7200, "0 */2 * * *")] + [InlineData(0, "*/1 * * * *")] + public void EveryCron_RoundsUpToWholeMinutes(int seconds, string expected) + { + Assert.Equal(expected, RecurringJobsRegistrar.EveryCron(seconds)); + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs index d1a0705..c829886 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/DeleteRoutingSlipBuilderTests.cs @@ -6,200 +6,201 @@ using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; using Musify.Infrastructure.Tests.TestSupport; using Xunit; -namespace Musify.Infrastructure.Tests.MassTransit; - -[Collection(InfrastructureCollection.Name)] -public sealed class DeleteRoutingSlipBuilderTests(InfrastructureTestFixture fixture) +namespace Musify.Infrastructure.Tests.MassTransit { - private static CancellationToken Ct => TestContext.Current.CancellationToken; - - [Fact] - public async Task BuildAlbum_WithProcessedPictures_RemovesEveryPictureBeforeDeletingTheRow() + [Collection(InfrastructureCollection.Name)] + public sealed class DeleteRoutingSlipBuilderTests(InfrastructureTestFixture fixture) { - var album = await SeedAlbumAsync(new EntityPictures + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task BuildAlbum_WithProcessedPictures_RemovesEveryPictureBeforeDeletingTheRow() { - OriginalName = "original.webp", - SmallName = "small.webp", - MediumName = "medium.webp", - LargeName = "large.webp" - }); - await using var database = fixture.CreateDatabase(); - - var slip = (await CreateBuilder(database).BuildAlbumAsync(album.Id, album.OwnerUserId, null, Ct)).Build(); - - Assert.Equal( - [ - ActivityNames.MarkAlbumAsRemoving, - ActivityNames.RemoveAlbumOriginalPicture, - ActivityNames.RemoveAlbumSmallPicture, - ActivityNames.RemoveAlbumMediumPicture, - ActivityNames.RemoveAlbumLargePicture, - ActivityNames.DeleteAlbumFromDb, - ], - slip.Itinerary.Select(activity => activity.Name)); - } - - [Fact] - public async Task BuildAlbum_WithPendingPicture_OnlyRemovesTheOriginal() - { - var album = await SeedAlbumAsync(EntityPictures.Pending("original.webp")); - await using var database = fixture.CreateDatabase(); - - var slip = (await CreateBuilder(database).BuildAlbumAsync(album.Id, album.OwnerUserId, null, Ct)).Build(); - - Assert.Equal( - [ActivityNames.MarkAlbumAsRemoving, ActivityNames.RemoveAlbumOriginalPicture, ActivityNames.DeleteAlbumFromDb], - slip.Itinerary.Select(activity => activity.Name)); - } - - [Fact] - public async Task BuildAlbum_MissingAlbum_OnlyMarksAndDeletes() - { - await using var database = fixture.CreateDatabase(); - - var slip = (await CreateBuilder(database).BuildAlbumAsync(Guid.NewGuid(), 1, null, Ct)).Build(); - - Assert.Equal( - [ActivityNames.MarkAlbumAsRemoving, ActivityNames.DeleteAlbumFromDb], - slip.Itinerary.Select(activity => activity.Name)); - } - - [Fact] - public async Task BuildPlayList_WithoutPictures_OnlyMarksAndDeletes() - { - var playList = await SeedPlayListAsync(pictures: null); - await using var database = fixture.CreateDatabase(); - - var slip = (await CreateBuilder(database).BuildPlayListAsync(playList.Id, playList.OwnerUserId, null, Ct)).Build(); - - Assert.Equal( - [ActivityNames.MarkPlayListAsRemoving, ActivityNames.DeletePlayListFromDb], - slip.Itinerary.Select(activity => activity.Name)); - } - - [Fact] - public async Task BuildPlayList_WithPictures_RemovesThemBetweenMarkAndDelete() - { - var playList = await SeedPlayListAsync(new EntityPictures { OriginalName = "original.webp", SmallName = "small.webp" }); - await using var database = fixture.CreateDatabase(); - - var slip = (await CreateBuilder(database).BuildPlayListAsync(playList.Id, playList.OwnerUserId, null, Ct)).Build(); - - Assert.Equal( - [ - ActivityNames.MarkPlayListAsRemoving, - ActivityNames.RemovePlayListOriginalPicture, - ActivityNames.RemovePlayListSmallPicture, - ActivityNames.DeletePlayListFromDb, - ], - slip.Itinerary.Select(activity => activity.Name)); - } - - [Fact] - public async Task BuildTrack_Unprocessed_NeverRemovesTheSharedPresetPictures() - { - var track = await SeedTrackAsync(new TrackPictures { OriginalName = "original.webp", SmallName = "preset.webp" }, new TrackAudio { OriginalName = "audio.mp3" }); - await using var database = fixture.CreateDatabase(); - - var slip = (await CreateBuilder(database).BuildTrackAsync(track.Id, track.OwnerUserId, null, Ct)).Build(); - - Assert.Equal( - [ - ActivityNames.MarkTrackAsRemoving, - ActivityNames.RemoveTrackOriginalPicture, - ActivityNames.RemoveTrackOriginalAudio, - ActivityNames.DeleteTrackFromDb, - ], - slip.Itinerary.Select(activity => activity.Name)); - } - - [Fact] - public async Task BuildTrack_Processed_RemovesResizedPicturesAndProcessedAudio() - { - var track = await SeedTrackAsync( - new TrackPictures + var album = await SeedAlbumAsync(new EntityPictures { OriginalName = "original.webp", - SmallName = "s.webp", - MediumName = "m.webp", - LargeName = "l.webp", - ProcessingStatus = ProcessingStatus.Completed - }, - new TrackAudio { OriginalName = "audio.mp3", FolderName = "folder", TranscodeStatus = ProcessingStatus.Completed }); - await using var database = fixture.CreateDatabase(); + SmallName = "small.webp", + MediumName = "medium.webp", + LargeName = "large.webp" + }); + await using var database = fixture.CreateDatabase(); - var slip = (await CreateBuilder(database).BuildTrackAsync(track.Id, track.OwnerUserId, null, Ct)).Build(); + var slip = (await CreateBuilder(database).BuildAlbumAsync(album.Id, album.OwnerUserId, null, Ct)).Build(); - Assert.Equal( - [ - ActivityNames.MarkTrackAsRemoving, - ActivityNames.RemoveTrackOriginalPicture, - ActivityNames.RemoveTrackSmallPicture, - ActivityNames.RemoveTrackMediumPicture, - ActivityNames.RemoveTrackLargePicture, - ActivityNames.RemoveTrackOriginalAudio, - ActivityNames.RemoveTrackProcessedAudio, - ActivityNames.DeleteTrackFromDb, - ], - slip.Itinerary.Select(activity => activity.Name)); - } + Assert.Equal( + [ + ActivityNames.MarkAlbumAsRemoving, + ActivityNames.RemoveAlbumOriginalPicture, + ActivityNames.RemoveAlbumSmallPicture, + ActivityNames.RemoveAlbumMediumPicture, + ActivityNames.RemoveAlbumLargePicture, + ActivityNames.DeleteAlbumFromDb, + ], + slip.Itinerary.Select(activity => activity.Name)); + } - private static DeleteRoutingSlipBuilder CreateBuilder(Musify.Application.Contracts.IDatabase database) => - new( - database, - new TrackConfiguration(), - new AlbumConfiguration(), - new PlayListConfiguration(), - new ApplicationStorageConfiguration { Bucket = "bucket" }); - - private async Task SeedUserAsync() - { - await using var database = fixture.CreateDatabase(); - var name = $"delete-{Guid.NewGuid():N}"[..30]; - var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; - await database.Users.AddAsync(user, Ct); - await database.SaveChangesAsync(Ct); - return user; - } - - private async Task SeedAlbumAsync(EntityPictures? pictures) - { - var user = await SeedUserAsync(); - await using var database = fixture.CreateDatabase(); - var title = $"album-{Guid.NewGuid():N}"; - var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; - await database.Albums.AddAsync(album, Ct); - await database.SaveChangesAsync(Ct); - return album; - } - - private async Task SeedPlayListAsync(EntityPictures? pictures) - { - var user = await SeedUserAsync(); - await using var database = fixture.CreateDatabase(); - var name = $"pl-{Guid.NewGuid():N}"[..30]; - var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; - await database.PlayLists.AddAsync(playList, Ct); - await database.SaveChangesAsync(Ct); - return playList; - } - - private async Task SeedTrackAsync(TrackPictures pictures, TrackAudio audio) - { - var user = await SeedUserAsync(); - await using var database = fixture.CreateDatabase(); - var title = $"track-{Guid.NewGuid():N}"; - var track = new Track + [Fact] + public async Task BuildAlbum_WithPendingPicture_OnlyRemovesTheOriginal() { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - DurationSeconds = 10, - OwnerUserId = user.Id, - Pictures = pictures, - Audio = audio - }; - await database.Tracks.AddAsync(track, Ct); - await database.SaveChangesAsync(Ct); - return track; + var album = await SeedAlbumAsync(EntityPictures.Pending("original.webp")); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildAlbumAsync(album.Id, album.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ActivityNames.MarkAlbumAsRemoving, ActivityNames.RemoveAlbumOriginalPicture, ActivityNames.DeleteAlbumFromDb], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildAlbum_MissingAlbum_OnlyMarksAndDeletes() + { + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildAlbumAsync(Guid.NewGuid(), 1, null, Ct)).Build(); + + Assert.Equal( + [ActivityNames.MarkAlbumAsRemoving, ActivityNames.DeleteAlbumFromDb], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildPlayList_WithoutPictures_OnlyMarksAndDeletes() + { + var playList = await SeedPlayListAsync(pictures: null); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildPlayListAsync(playList.Id, playList.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ActivityNames.MarkPlayListAsRemoving, ActivityNames.DeletePlayListFromDb], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildPlayList_WithPictures_RemovesThemBetweenMarkAndDelete() + { + var playList = await SeedPlayListAsync(new EntityPictures { OriginalName = "original.webp", SmallName = "small.webp" }); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildPlayListAsync(playList.Id, playList.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ + ActivityNames.MarkPlayListAsRemoving, + ActivityNames.RemovePlayListOriginalPicture, + ActivityNames.RemovePlayListSmallPicture, + ActivityNames.DeletePlayListFromDb, + ], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildTrack_Unprocessed_NeverRemovesTheSharedPresetPictures() + { + var track = await SeedTrackAsync(new TrackPictures { OriginalName = "original.webp", SmallName = "preset.webp" }, new TrackAudio { OriginalName = "audio.mp3" }); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildTrackAsync(track.Id, track.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ + ActivityNames.MarkTrackAsRemoving, + ActivityNames.RemoveTrackOriginalPicture, + ActivityNames.RemoveTrackOriginalAudio, + ActivityNames.DeleteTrackFromDb, + ], + slip.Itinerary.Select(activity => activity.Name)); + } + + [Fact] + public async Task BuildTrack_Processed_RemovesResizedPicturesAndProcessedAudio() + { + var track = await SeedTrackAsync( + new TrackPictures + { + OriginalName = "original.webp", + SmallName = "s.webp", + MediumName = "m.webp", + LargeName = "l.webp", + ProcessingStatus = ProcessingStatus.Completed + }, + new TrackAudio { OriginalName = "audio.mp3", FolderName = "folder", TranscodeStatus = ProcessingStatus.Completed }); + await using var database = fixture.CreateDatabase(); + + var slip = (await CreateBuilder(database).BuildTrackAsync(track.Id, track.OwnerUserId, null, Ct)).Build(); + + Assert.Equal( + [ + ActivityNames.MarkTrackAsRemoving, + ActivityNames.RemoveTrackOriginalPicture, + ActivityNames.RemoveTrackSmallPicture, + ActivityNames.RemoveTrackMediumPicture, + ActivityNames.RemoveTrackLargePicture, + ActivityNames.RemoveTrackOriginalAudio, + ActivityNames.RemoveTrackProcessedAudio, + ActivityNames.DeleteTrackFromDb, + ], + slip.Itinerary.Select(activity => activity.Name)); + } + + private static DeleteRoutingSlipBuilder CreateBuilder(Musify.Application.Contracts.IDatabase database) => + new( + database, + new TrackConfiguration(), + new AlbumConfiguration(), + new PlayListConfiguration(), + new ApplicationStorageConfiguration { Bucket = "bucket" }); + + private async Task SeedUserAsync() + { + await using var database = fixture.CreateDatabase(); + var name = $"delete-{Guid.NewGuid():N}"[..30]; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; + await database.Users.AddAsync(user, Ct); + await database.SaveChangesAsync(Ct); + return user; + } + + private async Task SeedAlbumAsync(EntityPictures? pictures) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"album-{Guid.NewGuid():N}"; + var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; + await database.Albums.AddAsync(album, Ct); + await database.SaveChangesAsync(Ct); + return album; + } + + private async Task SeedPlayListAsync(EntityPictures? pictures) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var name = $"pl-{Guid.NewGuid():N}"[..30]; + var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; + await database.PlayLists.AddAsync(playList, Ct); + await database.SaveChangesAsync(Ct); + return playList; + } + + private async Task SeedTrackAsync(TrackPictures pictures, TrackAudio audio) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"track-{Guid.NewGuid():N}"; + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + DurationSeconds = 10, + OwnerUserId = user.Id, + Pictures = pictures, + Audio = audio + }; + await database.Tracks.AddAsync(track, Ct); + await database.SaveChangesAsync(Ct); + return track; + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/LifeCycleActivityTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/LifeCycleActivityTests.cs index 2d73fa0..8c3930f 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/LifeCycleActivityTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/LifeCycleActivityTests.cs @@ -9,177 +9,178 @@ using Musify.Infrastructure.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Infrastructure.Tests.MassTransit; - -[Collection(InfrastructureCollection.Name)] -public sealed class LifeCycleActivityTests(InfrastructureTestFixture fixture) +namespace Musify.Infrastructure.Tests.MassTransit { - private static CancellationToken Ct => TestContext.Current.CancellationToken; - - [Fact] - public async Task MarkTrack_ExistingTrack_SetsTheStatus() + [Collection(InfrastructureCollection.Name)] + public sealed class LifeCycleActivityTests(InfrastructureTestFixture fixture) { - var track = await SeedTrackAsync(); - await using var database = fixture.CreateDatabase(); - var activity = new MarkTrackLifeCycleActivity(database, NullLogger.Instance); - var context = ExecuteContextFor(new MarkLifeCycleArguments(track.Id, LifeCycleStatus.Removing)); + private static CancellationToken Ct => TestContext.Current.CancellationToken; - await activity.Execute(context); - - context.Received(1).Completed(); - await using var verifier = fixture.CreateDatabase(); - var stored = await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct); - Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus); - } - - [Fact] - public async Task MarkAlbum_ExistingAlbum_SetsTheStatus() - { - var album = await SeedAlbumAsync(); - await using var database = fixture.CreateDatabase(); - var activity = new MarkAlbumLifeCycleActivity(database, NullLogger.Instance); - - await activity.Execute(ExecuteContextFor(new MarkLifeCycleArguments(album.Id, LifeCycleStatus.Failed))); - - await using var verifier = fixture.CreateDatabase(); - Assert.Equal(LifeCycleStatus.Failed, (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).LifeCycleStatus); - } - - [Fact] - public async Task MarkPlayList_ExistingPlayList_SetsTheStatus() - { - var playList = await SeedPlayListAsync(); - await using var database = fixture.CreateDatabase(); - var activity = new MarkPlayListLifeCycleActivity(database, NullLogger.Instance); - - await activity.Execute(ExecuteContextFor(new MarkLifeCycleArguments(playList.Id, LifeCycleStatus.Removing))); - - await using var verifier = fixture.CreateDatabase(); - Assert.Equal(LifeCycleStatus.Removing, (await verifier.PlayLists.AsNoTracking().SingleAsync(p => p.Id == playList.Id, Ct)).LifeCycleStatus); - } - - [Fact] - public async Task Mark_MissingEntity_CompletesWithoutFailing() - { - await using var database = fixture.CreateDatabase(); - var activity = new MarkTrackLifeCycleActivity(database, NullLogger.Instance); - var context = ExecuteContextFor(new MarkLifeCycleArguments(Guid.NewGuid(), LifeCycleStatus.Failed)); - - await activity.Execute(context); - - context.Received(1).Completed(); - } - - [Fact] - public async Task DeleteTrack_ExistingTrack_RemovesTheRow() - { - var track = await SeedTrackAsync(); - await using var database = fixture.CreateDatabase(); - var activity = new DeleteTrackActivity(database, NullLogger.Instance); - var context = ExecuteContextFor(new DeleteEntityArguments(track.Id)); - - await activity.Execute(context); - - context.Received(1).Completed(); - await using var verifier = fixture.CreateDatabase(); - Assert.False(await verifier.Tracks.AnyAsync(t => t.Id == track.Id, Ct)); - } - - [Fact] - public async Task DeleteAlbum_ExistingAlbum_RemovesTheRow() - { - var album = await SeedAlbumAsync(); - await using var database = fixture.CreateDatabase(); - var activity = new DeleteAlbumActivity(database, NullLogger.Instance); - - await activity.Execute(ExecuteContextFor(new DeleteEntityArguments(album.Id))); - - await using var verifier = fixture.CreateDatabase(); - Assert.False(await verifier.Albums.AnyAsync(a => a.Id == album.Id, Ct)); - } - - [Fact] - public async Task DeletePlayList_ExistingPlayList_RemovesTheRow() - { - var playList = await SeedPlayListAsync(); - await using var database = fixture.CreateDatabase(); - var activity = new DeletePlayListActivity(database, NullLogger.Instance); - - await activity.Execute(ExecuteContextFor(new DeleteEntityArguments(playList.Id))); - - await using var verifier = fixture.CreateDatabase(); - Assert.False(await verifier.PlayLists.AnyAsync(p => p.Id == playList.Id, Ct)); - } - - [Fact] - public async Task Delete_MissingEntity_CompletesWithoutFailing() - { - await using var database = fixture.CreateDatabase(); - var activity = new DeleteTrackActivity(database, NullLogger.Instance); - var context = ExecuteContextFor(new DeleteEntityArguments(Guid.NewGuid())); - - await activity.Execute(context); - - context.Received(1).Completed(); - } - - private static ExecuteContext ExecuteContextFor(TArguments arguments) - where TArguments : class - { - var context = Substitute.For>(); - context.Arguments.Returns(arguments); - context.CancellationToken.Returns(Ct); - return context; - } - - private async Task SeedUserAsync() - { - await using var database = fixture.CreateDatabase(); - var name = $"lifecycle-{Guid.NewGuid():N}"[..30]; - var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; - await database.Users.AddAsync(user, Ct); - await database.SaveChangesAsync(Ct); - return user; - } - - private async Task SeedTrackAsync() - { - var user = await SeedUserAsync(); - await using var database = fixture.CreateDatabase(); - var title = $"track-{Guid.NewGuid():N}"; - var track = new Track + [Fact] + public async Task MarkTrack_ExistingTrack_SetsTheStatus() { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - DurationSeconds = 10, - OwnerUserId = user.Id, - Pictures = new TrackPictures(), - Audio = new TrackAudio() - }; - await database.Tracks.AddAsync(track, Ct); - await database.SaveChangesAsync(Ct); - return track; - } + var track = await SeedTrackAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new MarkTrackLifeCycleActivity(database, NullLogger.Instance); + var context = ExecuteContextFor(new MarkLifeCycleArguments(track.Id, LifeCycleStatus.Removing)); - private async Task SeedAlbumAsync() - { - var user = await SeedUserAsync(); - await using var database = fixture.CreateDatabase(); - var title = $"album-{Guid.NewGuid():N}"; - var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id }; - await database.Albums.AddAsync(album, Ct); - await database.SaveChangesAsync(Ct); - return album; - } + await activity.Execute(context); - private async Task SeedPlayListAsync() - { - var user = await SeedUserAsync(); - await using var database = fixture.CreateDatabase(); - var name = $"pl-{Guid.NewGuid():N}"[..30]; - var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id }; - await database.PlayLists.AddAsync(playList, Ct); - await database.SaveChangesAsync(Ct); - return playList; + context.Received(1).Completed(); + await using var verifier = fixture.CreateDatabase(); + var stored = await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct); + Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus); + } + + [Fact] + public async Task MarkAlbum_ExistingAlbum_SetsTheStatus() + { + var album = await SeedAlbumAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new MarkAlbumLifeCycleActivity(database, NullLogger.Instance); + + await activity.Execute(ExecuteContextFor(new MarkLifeCycleArguments(album.Id, LifeCycleStatus.Failed))); + + await using var verifier = fixture.CreateDatabase(); + Assert.Equal(LifeCycleStatus.Failed, (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).LifeCycleStatus); + } + + [Fact] + public async Task MarkPlayList_ExistingPlayList_SetsTheStatus() + { + var playList = await SeedPlayListAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new MarkPlayListLifeCycleActivity(database, NullLogger.Instance); + + await activity.Execute(ExecuteContextFor(new MarkLifeCycleArguments(playList.Id, LifeCycleStatus.Removing))); + + await using var verifier = fixture.CreateDatabase(); + Assert.Equal(LifeCycleStatus.Removing, (await verifier.PlayLists.AsNoTracking().SingleAsync(p => p.Id == playList.Id, Ct)).LifeCycleStatus); + } + + [Fact] + public async Task Mark_MissingEntity_CompletesWithoutFailing() + { + await using var database = fixture.CreateDatabase(); + var activity = new MarkTrackLifeCycleActivity(database, NullLogger.Instance); + var context = ExecuteContextFor(new MarkLifeCycleArguments(Guid.NewGuid(), LifeCycleStatus.Failed)); + + await activity.Execute(context); + + context.Received(1).Completed(); + } + + [Fact] + public async Task DeleteTrack_ExistingTrack_RemovesTheRow() + { + var track = await SeedTrackAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new DeleteTrackActivity(database, NullLogger.Instance); + var context = ExecuteContextFor(new DeleteEntityArguments(track.Id)); + + await activity.Execute(context); + + context.Received(1).Completed(); + await using var verifier = fixture.CreateDatabase(); + Assert.False(await verifier.Tracks.AnyAsync(t => t.Id == track.Id, Ct)); + } + + [Fact] + public async Task DeleteAlbum_ExistingAlbum_RemovesTheRow() + { + var album = await SeedAlbumAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new DeleteAlbumActivity(database, NullLogger.Instance); + + await activity.Execute(ExecuteContextFor(new DeleteEntityArguments(album.Id))); + + await using var verifier = fixture.CreateDatabase(); + Assert.False(await verifier.Albums.AnyAsync(a => a.Id == album.Id, Ct)); + } + + [Fact] + public async Task DeletePlayList_ExistingPlayList_RemovesTheRow() + { + var playList = await SeedPlayListAsync(); + await using var database = fixture.CreateDatabase(); + var activity = new DeletePlayListActivity(database, NullLogger.Instance); + + await activity.Execute(ExecuteContextFor(new DeleteEntityArguments(playList.Id))); + + await using var verifier = fixture.CreateDatabase(); + Assert.False(await verifier.PlayLists.AnyAsync(p => p.Id == playList.Id, Ct)); + } + + [Fact] + public async Task Delete_MissingEntity_CompletesWithoutFailing() + { + await using var database = fixture.CreateDatabase(); + var activity = new DeleteTrackActivity(database, NullLogger.Instance); + var context = ExecuteContextFor(new DeleteEntityArguments(Guid.NewGuid())); + + await activity.Execute(context); + + context.Received(1).Completed(); + } + + private static ExecuteContext ExecuteContextFor(TArguments arguments) + where TArguments : class + { + var context = Substitute.For>(); + context.Arguments.Returns(arguments); + context.CancellationToken.Returns(Ct); + return context; + } + + private async Task SeedUserAsync() + { + await using var database = fixture.CreateDatabase(); + var name = $"lifecycle-{Guid.NewGuid():N}"[..30]; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; + await database.Users.AddAsync(user, Ct); + await database.SaveChangesAsync(Ct); + return user; + } + + private async Task SeedTrackAsync() + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"track-{Guid.NewGuid():N}"; + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + DurationSeconds = 10, + OwnerUserId = user.Id, + Pictures = new TrackPictures(), + Audio = new TrackAudio() + }; + await database.Tracks.AddAsync(track, Ct); + await database.SaveChangesAsync(Ct); + return track; + } + + private async Task SeedAlbumAsync() + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"album-{Guid.NewGuid():N}"; + var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id }; + await database.Albums.AddAsync(album, Ct); + await database.SaveChangesAsync(Ct); + return album; + } + + private async Task SeedPlayListAsync() + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var name = $"pl-{Guid.NewGuid():N}"[..30]; + var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id }; + await database.PlayLists.AddAsync(playList, Ct); + await database.SaveChangesAsync(Ct); + return playList; + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingSlipFaultConsumerTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingSlipFaultConsumerTests.cs index 590b01c..975319c 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingSlipFaultConsumerTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingSlipFaultConsumerTests.cs @@ -7,102 +7,103 @@ using Musify.Infrastructure.MassTransit.Consumers; using Musify.Infrastructure.MassTransit.RoutingSlip; using Xunit; -namespace Musify.Infrastructure.Tests.MassTransit; - -public sealed class ProcessingSlipFaultConsumerTests +namespace Musify.Infrastructure.Tests.MassTransit { - private static readonly Guid SubjectId = Guid.NewGuid(); - - private static CancellationToken Ct => TestContext.Current.CancellationToken; - - [Fact] - public async Task Consume_TrackPicture_PublishesTrackPictureProcessingFailed() => - await AssertPublishedAsync( - RoutingSlipVariableNames.ProcessKinds.TrackPicture, message => message.TrackId == SubjectId); - - [Fact] - public async Task Consume_TrackAudio_PublishesTrackAudioProcessingFailed() => - await AssertPublishedAsync( - RoutingSlipVariableNames.ProcessKinds.TrackAudio, message => message.TrackId == SubjectId); - - [Fact] - public async Task Consume_PlayListPicture_PublishesPlayListPictureProcessingFailed() => - await AssertPublishedAsync( - RoutingSlipVariableNames.ProcessKinds.PlayListPicture, message => message.PlayListId == SubjectId); - - [Fact] - public async Task Consume_AlbumPicture_PublishesAlbumPictureProcessingFailed() => - await AssertPublishedAsync( - RoutingSlipVariableNames.ProcessKinds.AlbumPicture, message => message.AlbumId == SubjectId); - - [Fact] - public async Task Consume_TrackCreation_PublishesTrackProcessingFailedWithKeys() => - await AssertPublishedAsync( - RoutingSlipVariableNames.ProcessKinds.TrackCreation, - message => message is { Bucket: "bucket", PictureKey: "picture-key", AudioKey: "audio-key" } && message.TrackId == SubjectId); - - [Fact] - public async Task Consume_AlbumCreation_PublishesAlbumProcessingFailedWithKeys() => - await AssertPublishedAsync( - RoutingSlipVariableNames.ProcessKinds.AlbumCreation, - message => message is { Bucket: "bucket", PictureKey: "picture-key" } && message.AlbumId == SubjectId); - - [Fact] - public async Task Consume_PlayListCreation_PublishesPlayListProcessingFailedWithKeys() => - await AssertPublishedAsync( - RoutingSlipVariableNames.ProcessKinds.PlayListCreation, - message => message is { Bucket: "bucket", PictureKey: "picture-key" } && message.PlayListId == SubjectId); - - [Fact] - public async Task Consume_UnknownKind_PublishesNothing() + public sealed class ProcessingSlipFaultConsumerTests { - await using var provider = CreateProvider(); - var harness = provider.GetRequiredService(); - await harness.Start(); + private static readonly Guid SubjectId = Guid.NewGuid(); - await harness.Bus.Publish(new { Variables = Variables("Something else") }, Ct); + private static CancellationToken Ct => TestContext.Current.CancellationToken; - Assert.True(await harness.Consumed.Any(Ct)); - Assert.False(await harness.Published.Any(Ct)); - Assert.False(await harness.Published.Any(Ct)); + [Fact] + public async Task Consume_TrackPicture_PublishesTrackPictureProcessingFailed() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.TrackPicture, message => message.TrackId == SubjectId); + + [Fact] + public async Task Consume_TrackAudio_PublishesTrackAudioProcessingFailed() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.TrackAudio, message => message.TrackId == SubjectId); + + [Fact] + public async Task Consume_PlayListPicture_PublishesPlayListPictureProcessingFailed() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.PlayListPicture, message => message.PlayListId == SubjectId); + + [Fact] + public async Task Consume_AlbumPicture_PublishesAlbumPictureProcessingFailed() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.AlbumPicture, message => message.AlbumId == SubjectId); + + [Fact] + public async Task Consume_TrackCreation_PublishesTrackProcessingFailedWithKeys() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.TrackCreation, + message => message is { Bucket: "bucket", PictureKey: "picture-key", AudioKey: "audio-key" } && message.TrackId == SubjectId); + + [Fact] + public async Task Consume_AlbumCreation_PublishesAlbumProcessingFailedWithKeys() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.AlbumCreation, + message => message is { Bucket: "bucket", PictureKey: "picture-key" } && message.AlbumId == SubjectId); + + [Fact] + public async Task Consume_PlayListCreation_PublishesPlayListProcessingFailedWithKeys() => + await AssertPublishedAsync( + RoutingSlipVariableNames.ProcessKinds.PlayListCreation, + message => message is { Bucket: "bucket", PictureKey: "picture-key" } && message.PlayListId == SubjectId); + + [Fact] + public async Task Consume_UnknownKind_PublishesNothing() + { + await using var provider = CreateProvider(); + var harness = provider.GetRequiredService(); + await harness.Start(); + + await harness.Bus.Publish(new { Variables = Variables("Something else") }, Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task Consume_WithoutProcessKind_PublishesNothing() + { + await using var provider = CreateProvider(); + var harness = provider.GetRequiredService(); + await harness.Start(); + + await harness.Bus.Publish(new { Variables = new Dictionary() }, Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + } + + private static async Task AssertPublishedAsync(string processKind, Func matches) + where TEvent : class + { + await using var provider = CreateProvider(); + var harness = provider.GetRequiredService(); + await harness.Start(); + + await harness.Bus.Publish(new { Variables = Variables(processKind) }, Ct); + + Assert.True(await harness.Published.Any(published => matches(published.Context.Message), Ct)); + } + + private static Dictionary Variables(string processKind) => new() + { + [RoutingSlipVariableNames.Workflow.ProcessKind] = processKind, + [RoutingSlipVariableNames.Workflow.SubjectId] = SubjectId, + [RoutingSlipVariableNames.Workflow.Bucket] = "bucket", + [RoutingSlipVariableNames.Workflow.PictureKey] = "picture-key", + [RoutingSlipVariableNames.Workflow.AudioKey] = "audio-key", + }; + + private static ServiceProvider CreateProvider() => + new ServiceCollection() + .AddMassTransitTestHarness(bus => bus.AddConsumer()) + .BuildServiceProvider(true); } - - [Fact] - public async Task Consume_WithoutProcessKind_PublishesNothing() - { - await using var provider = CreateProvider(); - var harness = provider.GetRequiredService(); - await harness.Start(); - - await harness.Bus.Publish(new { Variables = new Dictionary() }, Ct); - - Assert.True(await harness.Consumed.Any(Ct)); - Assert.False(await harness.Published.Any(Ct)); - } - - private static async Task AssertPublishedAsync(string processKind, Func matches) - where TEvent : class - { - await using var provider = CreateProvider(); - var harness = provider.GetRequiredService(); - await harness.Start(); - - await harness.Bus.Publish(new { Variables = Variables(processKind) }, Ct); - - Assert.True(await harness.Published.Any(published => matches(published.Context.Message), Ct)); - } - - private static Dictionary Variables(string processKind) => new() - { - [RoutingSlipVariableNames.Workflow.ProcessKind] = processKind, - [RoutingSlipVariableNames.Workflow.SubjectId] = SubjectId, - [RoutingSlipVariableNames.Workflow.Bucket] = "bucket", - [RoutingSlipVariableNames.Workflow.PictureKey] = "picture-key", - [RoutingSlipVariableNames.Workflow.AudioKey] = "audio-key", - }; - - private static ServiceProvider CreateProvider() => - new ServiceCollection() - .AddMassTransitTestHarness(bus => bus.AddConsumer()) - .BuildServiceProvider(true); } diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingStateMachineTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingStateMachineTests.cs index a86a199..25ff4f9 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingStateMachineTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/ProcessingStateMachineTests.cs @@ -6,202 +6,203 @@ using Musify.Application.Shared; using Musify.Infrastructure.MassTransit.Sagas; using Xunit; -namespace Musify.Infrastructure.Tests.MassTransit; - -public sealed class ProcessingStateMachineTests +namespace Musify.Infrastructure.Tests.MassTransit { - private static readonly ImageSizes Sizes = new(new ImageSize("s", 1, 1), new ImageSize("m", 2, 2), new ImageSize("l", 3, 3)); - - private static CancellationToken Ct => TestContext.Current.CancellationToken; - - // Track - - [Fact] - public async Task Track_PictureAndAudioProcessed_CompletesTheSaga() + public sealed class ProcessingStateMachineTests { - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - var saga = provider.GetRequiredService>(); - var id = Guid.NewGuid(); + private static readonly ImageSizes Sizes = new(new ImageSize("s", 1, 1), new ImageSize("m", 2, 2), new ImageSize("l", 3, 3)); - await harness.Bus.Publish(TrackStarted(id), Ct); - Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + private static CancellationToken Ct => TestContext.Current.CancellationToken; - await harness.Bus.Publish(new TrackPictureProcessed(id), Ct); - await harness.Bus.Publish(new TrackAudioProcessed(id), Ct); + // Track - Assert.Null(await saga.NotExists(id)); - Assert.False(await harness.Published.Any(Ct)); + [Fact] + public async Task Track_PictureAndAudioProcessed_CompletesTheSaga() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(TrackStarted(id), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new TrackPictureProcessed(id), Ct); + await harness.Bus.Publish(new TrackAudioProcessed(id), Ct); + + Assert.Null(await saga.NotExists(id)); + Assert.False(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task Track_PictureFailed_PublishesProcessingFailedAndMovesToFailed() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(TrackStarted(id), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new TrackPictureProcessingFailed(id), Ct); + + Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); + Assert.True(await harness.Published.Any( + published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture", AudioKey: "audio" } + && published.Context.Message.TrackId == id, Ct)); + } + + [Fact] + public async Task Track_AudioFailed_PublishesProcessingFailed() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(TrackStarted(id), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new TrackAudioProcessingFailed(id), Ct); + + Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); + Assert.True(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task Track_EventForMissingInstance_IsDiscarded() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new TrackPictureProcessed(id), Ct); + await harness.Bus.Publish(new TrackAudioProcessingFailed(id), Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any>(Ct)); + Assert.False(await harness.Published.Any>(Ct)); + Assert.False(await harness.Published.Any(Ct)); + } + + // Album + + [Fact] + public async Task Album_PictureProcessed_CompletesTheSaga() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new CreateAlbumResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new AlbumPictureProcessed(id), Ct); + + Assert.Null(await saga.NotExists(id)); + Assert.False(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task Album_PictureFailed_PublishesProcessingFailed() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new CreateAlbumResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new AlbumPictureProcessingFailed(id), Ct); + + Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); + Assert.True(await harness.Published.Any( + published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture" } + && published.Context.Message.AlbumId == id, Ct)); + } + + [Fact] + public async Task Album_EventForMissingInstance_IsDiscarded() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + await harness.Bus.Publish(new AlbumPictureProcessingFailed(Guid.NewGuid()), Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + Assert.False(await harness.Published.Any>(Ct)); + } + + // PlayList + + [Fact] + public async Task PlayList_PictureProcessed_CompletesTheSaga() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new CreatePlayListResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new PlayListPictureProcessed(id), Ct); + + Assert.Null(await saga.NotExists(id)); + Assert.False(await harness.Published.Any(Ct)); + } + + [Fact] + public async Task PlayList_PictureFailed_PublishesProcessingFailed() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + var saga = provider.GetRequiredService>(); + var id = Guid.NewGuid(); + + await harness.Bus.Publish(new CreatePlayListResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); + Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); + + await harness.Bus.Publish(new PlayListPictureProcessingFailed(id), Ct); + + Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); + Assert.True(await harness.Published.Any( + published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture" } + && published.Context.Message.PlayListId == id, Ct)); + } + + [Fact] + public async Task PlayList_EventForMissingInstance_IsDiscarded() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + await harness.Bus.Publish(new PlayListPictureProcessingFailed(Guid.NewGuid()), Ct); + + Assert.True(await harness.Consumed.Any(Ct)); + Assert.False(await harness.Published.Any(Ct)); + Assert.False(await harness.Published.Any>(Ct)); + } + + private static CreateTrackResourcesEvent TrackStarted(Guid id) => + new(id, Guid.NewGuid(), Guid.NewGuid(), "bucket", "picture-source", "picture", "audio-source", "audio", "audio-folder", Sizes); + + private static async Task StartAsync(ServiceProvider provider) + { + var harness = provider.GetRequiredService(); + await harness.Start(); + return harness; + } + + private static ServiceProvider CreateProvider() + where TMachine : class, SagaStateMachine + where TState : class, SagaStateMachineInstance => + new ServiceCollection() + .AddMassTransitTestHarness(bus => bus.AddSagaStateMachine().InMemoryRepository()) + .BuildServiceProvider(true); } - - [Fact] - public async Task Track_PictureFailed_PublishesProcessingFailedAndMovesToFailed() - { - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - var saga = provider.GetRequiredService>(); - var id = Guid.NewGuid(); - - await harness.Bus.Publish(TrackStarted(id), Ct); - Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); - - await harness.Bus.Publish(new TrackPictureProcessingFailed(id), Ct); - - Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); - Assert.True(await harness.Published.Any( - published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture", AudioKey: "audio" } - && published.Context.Message.TrackId == id, Ct)); - } - - [Fact] - public async Task Track_AudioFailed_PublishesProcessingFailed() - { - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - var saga = provider.GetRequiredService>(); - var id = Guid.NewGuid(); - - await harness.Bus.Publish(TrackStarted(id), Ct); - Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); - - await harness.Bus.Publish(new TrackAudioProcessingFailed(id), Ct); - - Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); - Assert.True(await harness.Published.Any(Ct)); - } - - [Fact] - public async Task Track_EventForMissingInstance_IsDiscarded() - { - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - var id = Guid.NewGuid(); - - await harness.Bus.Publish(new TrackPictureProcessed(id), Ct); - await harness.Bus.Publish(new TrackAudioProcessingFailed(id), Ct); - - Assert.True(await harness.Consumed.Any(Ct)); - Assert.False(await harness.Published.Any>(Ct)); - Assert.False(await harness.Published.Any>(Ct)); - Assert.False(await harness.Published.Any(Ct)); - } - - // Album - - [Fact] - public async Task Album_PictureProcessed_CompletesTheSaga() - { - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - var saga = provider.GetRequiredService>(); - var id = Guid.NewGuid(); - - await harness.Bus.Publish(new CreateAlbumResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); - Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); - - await harness.Bus.Publish(new AlbumPictureProcessed(id), Ct); - - Assert.Null(await saga.NotExists(id)); - Assert.False(await harness.Published.Any(Ct)); - } - - [Fact] - public async Task Album_PictureFailed_PublishesProcessingFailed() - { - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - var saga = provider.GetRequiredService>(); - var id = Guid.NewGuid(); - - await harness.Bus.Publish(new CreateAlbumResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); - Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); - - await harness.Bus.Publish(new AlbumPictureProcessingFailed(id), Ct); - - Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); - Assert.True(await harness.Published.Any( - published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture" } - && published.Context.Message.AlbumId == id, Ct)); - } - - [Fact] - public async Task Album_EventForMissingInstance_IsDiscarded() - { - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - - await harness.Bus.Publish(new AlbumPictureProcessingFailed(Guid.NewGuid()), Ct); - - Assert.True(await harness.Consumed.Any(Ct)); - Assert.False(await harness.Published.Any(Ct)); - Assert.False(await harness.Published.Any>(Ct)); - } - - // PlayList - - [Fact] - public async Task PlayList_PictureProcessed_CompletesTheSaga() - { - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - var saga = provider.GetRequiredService>(); - var id = Guid.NewGuid(); - - await harness.Bus.Publish(new CreatePlayListResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); - Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); - - await harness.Bus.Publish(new PlayListPictureProcessed(id), Ct); - - Assert.Null(await saga.NotExists(id)); - Assert.False(await harness.Published.Any(Ct)); - } - - [Fact] - public async Task PlayList_PictureFailed_PublishesProcessingFailed() - { - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - var saga = provider.GetRequiredService>(); - var id = Guid.NewGuid(); - - await harness.Bus.Publish(new CreatePlayListResourcesEvent(id, Guid.NewGuid(), "bucket", "source", "picture", Sizes), Ct); - Assert.NotNull(await saga.Exists(id, machine => machine.Processing)); - - await harness.Bus.Publish(new PlayListPictureProcessingFailed(id), Ct); - - Assert.NotNull(await saga.Exists(id, machine => machine.Failed)); - Assert.True(await harness.Published.Any( - published => published.Context.Message is { Bucket: "bucket", PictureKey: "picture" } - && published.Context.Message.PlayListId == id, Ct)); - } - - [Fact] - public async Task PlayList_EventForMissingInstance_IsDiscarded() - { - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - - await harness.Bus.Publish(new PlayListPictureProcessingFailed(Guid.NewGuid()), Ct); - - Assert.True(await harness.Consumed.Any(Ct)); - Assert.False(await harness.Published.Any(Ct)); - Assert.False(await harness.Published.Any>(Ct)); - } - - private static CreateTrackResourcesEvent TrackStarted(Guid id) => - new(id, Guid.NewGuid(), Guid.NewGuid(), "bucket", "picture-source", "picture", "audio-source", "audio", "audio-folder", Sizes); - - private static async Task StartAsync(ServiceProvider provider) - { - var harness = provider.GetRequiredService(); - await harness.Start(); - return harness; - } - - private static ServiceProvider CreateProvider() - where TMachine : class, SagaStateMachine - where TState : class, SagaStateMachineInstance => - new ServiceCollection() - .AddMassTransitTestHarness(bus => bus.AddSagaStateMachine().InMemoryRepository()) - .BuildServiceProvider(true); } diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/RoutingSlipBuilderTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/RoutingSlipBuilderTests.cs index b35c96b..de09a5b 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/RoutingSlipBuilderTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/RoutingSlipBuilderTests.cs @@ -7,123 +7,124 @@ using Musify.Infrastructure.MassTransit.RoutingSlip; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; using Xunit; -namespace Musify.Infrastructure.Tests.MassTransit; - -public sealed class RoutingSlipBuilderTests +namespace Musify.Infrastructure.Tests.MassTransit { - private static readonly ImageSizes Sizes = new(new ImageSize("s", 1, 1), new ImageSize("m", 2, 2), new ImageSize("l", 3, 3)); - - private static readonly Guid SubjectId = Guid.NewGuid(); - private static readonly Guid IntentId = Guid.NewGuid(); - private static readonly Guid CorrelationId = Guid.NewGuid(); - - [Fact] - public void Create_AlwaysSubscribesTheCleanUpConsumerAndCarriesTheCorrelationId() + public sealed class RoutingSlipBuilderTests { - var slip = RoutingSlips.Create(CorrelationId).Build(); + private static readonly ImageSizes Sizes = new(new ImageSize("s", 1, 1), new ImageSize("m", 2, 2), new ImageSize("l", 3, 3)); - Assert.Equal(CorrelationId, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]); - var subscription = Assert.Single(slip.Subscriptions); - Assert.Equal(RoutingSlips.ConsumerUri(RoutingSlipCleanUpConsumer.QueueName), subscription.Address); - Assert.Equal(RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted, subscription.Events); - } + private static readonly Guid SubjectId = Guid.NewGuid(); + private static readonly Guid IntentId = Guid.NewGuid(); + private static readonly Guid CorrelationId = Guid.NewGuid(); - [Fact] - public void Create_WithoutCorrelationId_UsesEmptyGuid() - { - var slip = RoutingSlips.Create(null).Build(); + [Fact] + public void Create_AlwaysSubscribesTheCleanUpConsumerAndCarriesTheCorrelationId() + { + var slip = RoutingSlips.Create(CorrelationId).Build(); - Assert.Equal(Guid.Empty, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]); - } + Assert.Equal(CorrelationId, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]); + var subscription = Assert.Single(slip.Subscriptions); + Assert.Equal(RoutingSlips.ConsumerUri(RoutingSlipCleanUpConsumer.QueueName), subscription.Address); + Assert.Equal(RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted, subscription.Events); + } - [Fact] - public void TrackFaults_AddsSubjectProcessKindAndFaultSubscription() - { - var slip = RoutingSlips.Create(null).TrackFaults(SubjectId, "Kind").Build(); + [Fact] + public void Create_WithoutCorrelationId_UsesEmptyGuid() + { + var slip = RoutingSlips.Create(null).Build(); - Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]); - Assert.Equal("Kind", slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]); - Assert.Contains(slip.Subscriptions, subscription => - subscription.Address == RoutingSlips.ConsumerUri(ProcessingSlipFaultConsumer.QueueName) - && subscription.Events == RoutingSlipEvents.Faulted); - } + Assert.Equal(Guid.Empty, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]); + } - [Fact] - public void PictureSource_CreateAlbum_CopiesConsumesAndPublishesInOrder() - { - var message = new CreateAlbumResourcesEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); + [Fact] + public void TrackFaults_AddsSubjectProcessKindAndFaultSubscription() + { + var slip = RoutingSlips.Create(null).TrackFaults(SubjectId, "Kind").Build(); - var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); + Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]); + Assert.Equal("Kind", slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]); + Assert.Contains(slip.Subscriptions, subscription => + subscription.Address == RoutingSlips.ConsumerUri(ProcessingSlipFaultConsumer.QueueName) + && subscription.Events == RoutingSlipEvents.Faulted); + } - AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.AlbumCreation, ActivityNames.PublishAlbumPictureProcessingEvent); - } + [Fact] + public void PictureSource_CreateAlbum_CopiesConsumesAndPublishesInOrder() + { + var message = new CreateAlbumResourcesEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); - [Fact] - public void PictureSource_UpdateAlbum_UsesTheAlbumPictureProcessKind() - { - var message = new UpdateAlbumPictureSourceEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); + var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); - var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); + AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.AlbumCreation, ActivityNames.PublishAlbumPictureProcessingEvent); + } - AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.AlbumPicture, ActivityNames.PublishAlbumPictureProcessingEvent); - } + [Fact] + public void PictureSource_UpdateAlbum_UsesTheAlbumPictureProcessKind() + { + var message = new UpdateAlbumPictureSourceEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); - [Fact] - public void PictureSource_CreatePlayList_CopiesConsumesAndPublishesInOrder() - { - var message = new CreatePlayListResourcesEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); + var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); - var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); + AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.AlbumPicture, ActivityNames.PublishAlbumPictureProcessingEvent); + } - AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.PlayListCreation, ActivityNames.PublishPlayListPictureProcessingEvent); - } + [Fact] + public void PictureSource_CreatePlayList_CopiesConsumesAndPublishesInOrder() + { + var message = new CreatePlayListResourcesEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); - [Fact] - public void PictureSource_UpdatePlayList_UsesThePlayListPictureProcessKind() - { - var message = new UpdatePlayListPictureSourceEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); + var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); - var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); + AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.PlayListCreation, ActivityNames.PublishPlayListPictureProcessingEvent); + } - AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.PlayListPicture, ActivityNames.PublishPlayListPictureProcessingEvent); - } + [Fact] + public void PictureSource_UpdatePlayList_UsesThePlayListPictureProcessKind() + { + var message = new UpdatePlayListPictureSourceEvent(SubjectId, IntentId, "bucket", "source-key", "final-key", Sizes); - [Fact] - public void AudioWorkflow_ChainsDownloadTranscodeTransferAndUpdate() - { - var configuration = new WorkerConfiguration(); - var message = new UpdateTrackAudioEvent(SubjectId, "source-bucket", "source-key", "destination-bucket", "destination-folder"); + var slip = new PictureSourceRoutingSlipBuilder().Build(message, CorrelationId).Build(); - var slip = new AudioWorkflowRoutingSlipBuilder(configuration).Build(message, CorrelationId).Build(); + AssertPictureSourceSlip(slip, RoutingSlipVariableNames.ProcessKinds.PlayListPicture, ActivityNames.PublishPlayListPictureProcessingEvent); + } - Assert.Equal( - [ - ActivityNames.GenerateAudioWorkflowPaths, - ActivityNames.DownloadFile, - ActivityNames.TranscodeAudio, - ActivityNames.TransferFiles, - ActivityNames.UpdateTrackAudio, - ], - slip.Itinerary.Select(activity => activity.Name)); - Assert.Equal(RoutingSlipVariableNames.ProcessKinds.TrackAudio, slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]); - Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]); - Assert.All(slip.Itinerary, activity => Assert.EndsWith("_execute", activity.Address.ToString())); - } + [Fact] + public void AudioWorkflow_ChainsDownloadTranscodeTransferAndUpdate() + { + var configuration = new WorkerConfiguration(); + var message = new UpdateTrackAudioEvent(SubjectId, "source-bucket", "source-key", "destination-bucket", "destination-folder"); - private static void AssertPictureSourceSlip(RoutingSlip slip, string processKind, string publishStep) - { - Assert.Equal( - [ActivityNames.CopyPictureToFinal, ActivityNames.ConsumeUploadIntents, publishStep], - slip.Itinerary.Select(activity => activity.Name)); + var slip = new AudioWorkflowRoutingSlipBuilder(configuration).Build(message, CorrelationId).Build(); - Assert.Equal(processKind, slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]); - Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]); - Assert.Equal("bucket", slip.Variables[RoutingSlipVariableNames.Workflow.Bucket]); - Assert.Equal("final-key", slip.Variables[RoutingSlipVariableNames.Workflow.PictureKey]); - Assert.Equal(CorrelationId, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]); + Assert.Equal( + [ + ActivityNames.GenerateAudioWorkflowPaths, + ActivityNames.DownloadFile, + ActivityNames.TranscodeAudio, + ActivityNames.TransferFiles, + ActivityNames.UpdateTrackAudio, + ], + slip.Itinerary.Select(activity => activity.Name)); + Assert.Equal(RoutingSlipVariableNames.ProcessKinds.TrackAudio, slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]); + Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]); + Assert.All(slip.Itinerary, activity => Assert.EndsWith("_execute", activity.Address.ToString())); + } - var copy = new Dictionary(slip.Itinerary[0].Arguments, StringComparer.OrdinalIgnoreCase); - Assert.Equal("source-key", copy["SourceKey"]?.ToString()); - Assert.Equal("final-key", copy["DestinationKey"]?.ToString()); + private static void AssertPictureSourceSlip(RoutingSlip slip, string processKind, string publishStep) + { + Assert.Equal( + [ActivityNames.CopyPictureToFinal, ActivityNames.ConsumeUploadIntents, publishStep], + slip.Itinerary.Select(activity => activity.Name)); + + Assert.Equal(processKind, slip.Variables[RoutingSlipVariableNames.Workflow.ProcessKind]); + Assert.Equal(SubjectId, slip.Variables[RoutingSlipVariableNames.Workflow.SubjectId]); + Assert.Equal("bucket", slip.Variables[RoutingSlipVariableNames.Workflow.Bucket]); + Assert.Equal("final-key", slip.Variables[RoutingSlipVariableNames.Workflow.PictureKey]); + Assert.Equal(CorrelationId, slip.Variables[RoutingSlipVariableNames.Workflow.CorrelationId]); + + var copy = new Dictionary(slip.Itinerary[0].Arguments, StringComparer.OrdinalIgnoreCase); + Assert.Equal("source-key", copy["SourceKey"]?.ToString()); + Assert.Equal("final-key", copy["DestinationKey"]?.ToString()); + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/UpdatePicturesActivityTests.cs b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/UpdatePicturesActivityTests.cs index 3378b39..0f85942 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/MassTransit/UpdatePicturesActivityTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/MassTransit/UpdatePicturesActivityTests.cs @@ -14,247 +14,248 @@ using Musify.Infrastructure.MassTransit.RoutingSlip; using Musify.Infrastructure.Tests.TestSupport; using Xunit; -namespace Musify.Infrastructure.Tests.MassTransit; - -/// Runs the picture-update activities inside real routing slips on the MassTransit test harness. -[Collection(InfrastructureCollection.Name)] -public sealed class UpdatePicturesActivityTests(InfrastructureTestFixture fixture) +namespace Musify.Infrastructure.Tests.MassTransit { - private const string SmallVariable = "Picture.Small"; - private const string MediumVariable = "Picture.Medium"; - private const string LargeVariable = "Picture.Large"; - - private static CancellationToken Ct => TestContext.Current.CancellationToken; - - [Fact] - public async Task Album_Execute_StoresTheNamesAndPublishesTheEvent() + /// Runs the picture-update activities inside real routing slips on the MassTransit test harness. + [Collection(InfrastructureCollection.Name)] + public sealed class UpdatePicturesActivityTests(InfrastructureTestFixture fixture) { - var album = await SeedAlbumAsync(EntityPictures.Pending("old-original.webp")); - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); + private const string SmallVariable = "Picture.Small"; + private const string MediumVariable = "Picture.Medium"; + private const string LargeVariable = "Picture.Large"; - var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, album.Id, "uploads/1/albums/new-original.webp"); - await harness.Bus.Execute(slip.Build(), Ct); + private static CancellationToken Ct => TestContext.Current.CancellationToken; - Assert.True(await harness.Sent.Any(Ct)); - await using var verifier = fixture.CreateDatabase(); - var stored = (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!; - Assert.Equal("new-original.webp", stored.OriginalName); - Assert.Equal("small.webp", stored.SmallName); - Assert.Equal("medium.webp", stored.MediumName); - Assert.Equal("large.webp", stored.LargeName); - Assert.True(await harness.Published.Any(published => published.Context.Message.AlbumId == album.Id, Ct)); - } - - [Fact] - public async Task PlayList_Execute_StoresTheNamesAndPublishesTheEvent() - { - var playList = await SeedPlayListAsync(EntityPictures.Pending("old.webp")); - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - - var slip = SlipFor(UpdatePlayListPictureActivity.ExecuteEndpointName, playList.Id, "uploads/1/playlists/new.webp"); - await harness.Bus.Execute(slip.Build(), Ct); - - Assert.True(await harness.Sent.Any(Ct)); - await using var verifier = fixture.CreateDatabase(); - var stored = (await verifier.PlayLists.AsNoTracking().SingleAsync(p => p.Id == playList.Id, Ct)).Pictures!; - Assert.Equal("new.webp", stored.OriginalName); - Assert.Equal("small.webp", stored.SmallName); - Assert.True(await harness.Published.Any(published => published.Context.Message.PlayListId == playList.Id, Ct)); - } - - [Fact] - public async Task Track_Execute_MarksThePicturesAsCompleted() - { - var track = await SeedTrackAsync(); - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - - var slip = SlipFor(UpdateTrackPictureActivity.ExecuteEndpointName, track.Id, "uploads/1/tracks/new.webp"); - await harness.Bus.Execute(slip.Build(), Ct); - - Assert.True(await harness.Sent.Any(Ct)); - await using var verifier = fixture.CreateDatabase(); - var stored = (await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures; - Assert.Equal(ProcessingStatus.Completed, stored.ProcessingStatus); - Assert.Equal("large.webp", stored.LargeName); - Assert.True(await harness.Published.Any(published => published.Context.Message.TrackId == track.Id, Ct)); - } - - [Fact] - public async Task Execute_MissingEntity_FaultsTheSlip() - { - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - - var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, Guid.NewGuid(), "uploads/1/albums/x.webp"); - await harness.Bus.Execute(slip.Build(), Ct); - - Assert.True(await harness.Sent.Any(Ct)); - } - - [Fact] - public async Task Execute_MissingVariable_FaultsTheSlip() - { - var album = await SeedAlbumAsync(EntityPictures.Pending("old.webp")); - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - - var slip = RoutingSlips.Create(null).AddStep( - "Update", - UpdateAlbumPictureActivity.ExecuteEndpointName, - new UpdatePicturesArguments(album.Id, "key.webp", SmallVariable, MediumVariable, LargeVariable)); - await harness.Bus.Execute(slip.Build(), Ct); - - Assert.True(await harness.Sent.Any(Ct)); - await using var verifier = fixture.CreateDatabase(); - Assert.Equal("old.webp", (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!.OriginalName); - } - - [Fact] - public async Task Album_LaterStepFails_CompensationRestoresThePreviousNames() - { - var album = await SeedAlbumAsync(EntityPictures.Pending("old-original.webp")); - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); - - var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, album.Id, "uploads/1/albums/new-original.webp"); - slip.AddStep("Fail", AlwaysFailsActivity.ExecuteEndpointName, new AlwaysFailsArguments()); - await harness.Bus.Execute(slip.Build(), Ct); - - Assert.True(await harness.Sent.Any(Ct)); - await WaitUntilAsync(async () => + [Fact] + public async Task Album_Execute_StoresTheNamesAndPublishesTheEvent() { - await using var poll = fixture.CreateDatabase(); - return (await poll.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!.OriginalName == "old-original.webp"; - }); - await using var verifier = fixture.CreateDatabase(); - var stored = (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!; - Assert.Equal("old-original.webp", stored.OriginalName); - Assert.Null(stored.SmallName); - Assert.Null(stored.MediumName); - Assert.Null(stored.LargeName); - } + var album = await SeedAlbumAsync(EntityPictures.Pending("old-original.webp")); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); - [Fact] - public async Task Track_LaterStepFails_CompensationMarksThePicturesAsFailed() - { - var track = await SeedTrackAsync(); - await using var provider = CreateProvider(); - var harness = await StartAsync(provider); + var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, album.Id, "uploads/1/albums/new-original.webp"); + await harness.Bus.Execute(slip.Build(), Ct); - var slip = SlipFor(UpdateTrackPictureActivity.ExecuteEndpointName, track.Id, "uploads/1/tracks/new.webp"); - slip.AddStep("Fail", AlwaysFailsActivity.ExecuteEndpointName, new AlwaysFailsArguments()); - await harness.Bus.Execute(slip.Build(), Ct); + Assert.True(await harness.Sent.Any(Ct)); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!; + Assert.Equal("new-original.webp", stored.OriginalName); + Assert.Equal("small.webp", stored.SmallName); + Assert.Equal("medium.webp", stored.MediumName); + Assert.Equal("large.webp", stored.LargeName); + Assert.True(await harness.Published.Any(published => published.Context.Message.AlbumId == album.Id, Ct)); + } - Assert.True(await harness.Sent.Any(Ct)); - await WaitUntilAsync(async () => + [Fact] + public async Task PlayList_Execute_StoresTheNamesAndPublishesTheEvent() { - await using var poll = fixture.CreateDatabase(); - return (await poll.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures.ProcessingStatus == ProcessingStatus.Failed; - }); - await using var verifier = fixture.CreateDatabase(); - var stored = (await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures; - Assert.Equal(ProcessingStatus.Failed, stored.ProcessingStatus); - Assert.Equal("old.webp", stored.OriginalName); - } + var playList = await SeedPlayListAsync(EntityPictures.Pending("old.webp")); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); - private static async Task WaitUntilAsync(Func> condition) - { - var deadline = DateTime.UtcNow.AddSeconds(10); - while (!await condition() && DateTime.UtcNow < deadline) - await Task.Delay(100, Ct); - } + var slip = SlipFor(UpdatePlayListPictureActivity.ExecuteEndpointName, playList.Id, "uploads/1/playlists/new.webp"); + await harness.Bus.Execute(slip.Build(), Ct); - private static RoutingSlipBuilder SlipFor(string endpointName, Guid subjectId, string originalKey) - { - var builder = RoutingSlips.Create(null).AddStep( - "Update", endpointName, new UpdatePicturesArguments(subjectId, originalKey, SmallVariable, MediumVariable, LargeVariable)); - builder.AddVariable(SmallVariable, "pictures/small.webp"); - builder.AddVariable(MediumVariable, "pictures/medium.webp"); - builder.AddVariable(LargeVariable, "pictures/large.webp"); - return builder; - } + Assert.True(await harness.Sent.Any(Ct)); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.PlayLists.AsNoTracking().SingleAsync(p => p.Id == playList.Id, Ct)).Pictures!; + Assert.Equal("new.webp", stored.OriginalName); + Assert.Equal("small.webp", stored.SmallName); + Assert.True(await harness.Published.Any(published => published.Context.Message.PlayListId == playList.Id, Ct)); + } - private ServiceProvider CreateProvider() => - new ServiceCollection() - .AddScoped(_ => fixture.CreateDatabase()) - .AddMassTransitTestHarness(bus => + [Fact] + public async Task Track_Execute_MarksThePicturesAsCompleted() + { + var track = await SeedTrackAsync(); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = SlipFor(UpdateTrackPictureActivity.ExecuteEndpointName, track.Id, "uploads/1/tracks/new.webp"); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures; + Assert.Equal(ProcessingStatus.Completed, stored.ProcessingStatus); + Assert.Equal("large.webp", stored.LargeName); + Assert.True(await harness.Published.Any(published => published.Context.Message.TrackId == track.Id, Ct)); + } + + [Fact] + public async Task Execute_MissingEntity_FaultsTheSlip() + { + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, Guid.NewGuid(), "uploads/1/albums/x.webp"); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + } + + [Fact] + public async Task Execute_MissingVariable_FaultsTheSlip() + { + var album = await SeedAlbumAsync(EntityPictures.Pending("old.webp")); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = RoutingSlips.Create(null).AddStep( + "Update", + UpdateAlbumPictureActivity.ExecuteEndpointName, + new UpdatePicturesArguments(album.Id, "key.webp", SmallVariable, MediumVariable, LargeVariable)); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + await using var verifier = fixture.CreateDatabase(); + Assert.Equal("old.webp", (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!.OriginalName); + } + + [Fact] + public async Task Album_LaterStepFails_CompensationRestoresThePreviousNames() + { + var album = await SeedAlbumAsync(EntityPictures.Pending("old-original.webp")); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); + + var slip = SlipFor(UpdateAlbumPictureActivity.ExecuteEndpointName, album.Id, "uploads/1/albums/new-original.webp"); + slip.AddStep("Fail", AlwaysFailsActivity.ExecuteEndpointName, new AlwaysFailsArguments()); + await harness.Bus.Execute(slip.Build(), Ct); + + Assert.True(await harness.Sent.Any(Ct)); + await WaitUntilAsync(async () => { - bus.SetKebabCaseEndpointNameFormatter(); - bus.AddActivity(); - bus.AddActivity(); - bus.AddActivity(); - bus.AddExecuteActivity(); - }) - .BuildServiceProvider(true); + await using var poll = fixture.CreateDatabase(); + return (await poll.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!.OriginalName == "old-original.webp"; + }); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.Albums.AsNoTracking().SingleAsync(a => a.Id == album.Id, Ct)).Pictures!; + Assert.Equal("old-original.webp", stored.OriginalName); + Assert.Null(stored.SmallName); + Assert.Null(stored.MediumName); + Assert.Null(stored.LargeName); + } - private static async Task StartAsync(ServiceProvider provider) - { - var harness = provider.GetRequiredService(); - await harness.Start(); - return harness; - } - - private async Task SeedUserAsync() - { - await using var database = fixture.CreateDatabase(); - var name = $"pictures-{Guid.NewGuid():N}"[..30]; - var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; - await database.Users.AddAsync(user, Ct); - await database.SaveChangesAsync(Ct); - return user; - } - - private async Task SeedAlbumAsync(EntityPictures pictures) - { - var user = await SeedUserAsync(); - await using var database = fixture.CreateDatabase(); - var title = $"album-{Guid.NewGuid():N}"; - var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; - await database.Albums.AddAsync(album, Ct); - await database.SaveChangesAsync(Ct); - return album; - } - - private async Task SeedPlayListAsync(EntityPictures pictures) - { - var user = await SeedUserAsync(); - await using var database = fixture.CreateDatabase(); - var name = $"pl-{Guid.NewGuid():N}"[..30]; - var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; - await database.PlayLists.AddAsync(playList, Ct); - await database.SaveChangesAsync(Ct); - return playList; - } - - private async Task SeedTrackAsync() - { - var user = await SeedUserAsync(); - await using var database = fixture.CreateDatabase(); - var title = $"track-{Guid.NewGuid():N}"; - var track = new Track + [Fact] + public async Task Track_LaterStepFails_CompensationMarksThePicturesAsFailed() { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - DurationSeconds = 10, - OwnerUserId = user.Id, - Pictures = new TrackPictures { OriginalName = "old.webp" }, - Audio = new TrackAudio() - }; - await database.Tracks.AddAsync(track, Ct); - await database.SaveChangesAsync(Ct); - return track; - } + var track = await SeedTrackAsync(); + await using var provider = CreateProvider(); + var harness = await StartAsync(provider); - public sealed record AlwaysFailsArguments; + var slip = SlipFor(UpdateTrackPictureActivity.ExecuteEndpointName, track.Id, "uploads/1/tracks/new.webp"); + slip.AddStep("Fail", AlwaysFailsActivity.ExecuteEndpointName, new AlwaysFailsArguments()); + await harness.Bus.Execute(slip.Build(), Ct); - public sealed class AlwaysFailsActivity : IExecuteActivity - { - public const string ExecuteEndpointName = "always-fails"; + Assert.True(await harness.Sent.Any(Ct)); + await WaitUntilAsync(async () => + { + await using var poll = fixture.CreateDatabase(); + return (await poll.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures.ProcessingStatus == ProcessingStatus.Failed; + }); + await using var verifier = fixture.CreateDatabase(); + var stored = (await verifier.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, Ct)).Pictures; + Assert.Equal(ProcessingStatus.Failed, stored.ProcessingStatus); + Assert.Equal("old.webp", stored.OriginalName); + } - public Task Execute(ExecuteContext context) => - throw new InvalidOperationException("Forced failure"); + private static async Task WaitUntilAsync(Func> condition) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + while (!await condition() && DateTime.UtcNow < deadline) + await Task.Delay(100, Ct); + } + + private static RoutingSlipBuilder SlipFor(string endpointName, Guid subjectId, string originalKey) + { + var builder = RoutingSlips.Create(null).AddStep( + "Update", endpointName, new UpdatePicturesArguments(subjectId, originalKey, SmallVariable, MediumVariable, LargeVariable)); + builder.AddVariable(SmallVariable, "pictures/small.webp"); + builder.AddVariable(MediumVariable, "pictures/medium.webp"); + builder.AddVariable(LargeVariable, "pictures/large.webp"); + return builder; + } + + private ServiceProvider CreateProvider() => + new ServiceCollection() + .AddScoped(_ => fixture.CreateDatabase()) + .AddMassTransitTestHarness(bus => + { + bus.SetKebabCaseEndpointNameFormatter(); + bus.AddActivity(); + bus.AddActivity(); + bus.AddActivity(); + bus.AddExecuteActivity(); + }) + .BuildServiceProvider(true); + + private static async Task StartAsync(ServiceProvider provider) + { + var harness = provider.GetRequiredService(); + await harness.Start(); + return harness; + } + + private async Task SeedUserAsync() + { + await using var database = fixture.CreateDatabase(); + var name = $"pictures-{Guid.NewGuid():N}"[..30]; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; + await database.Users.AddAsync(user, Ct); + await database.SaveChangesAsync(Ct); + return user; + } + + private async Task SeedAlbumAsync(EntityPictures pictures) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"album-{Guid.NewGuid():N}"; + var album = new Album { Title = title, NormalizedTitle = title.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; + await database.Albums.AddAsync(album, Ct); + await database.SaveChangesAsync(Ct); + return album; + } + + private async Task SeedPlayListAsync(EntityPictures pictures) + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var name = $"pl-{Guid.NewGuid():N}"[..30]; + var playList = new PlayList { Name = name, NormalizedName = name.ToUpperInvariant(), OwnerUserId = user.Id, Pictures = pictures }; + await database.PlayLists.AddAsync(playList, Ct); + await database.SaveChangesAsync(Ct); + return playList; + } + + private async Task SeedTrackAsync() + { + var user = await SeedUserAsync(); + await using var database = fixture.CreateDatabase(); + var title = $"track-{Guid.NewGuid():N}"; + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + DurationSeconds = 10, + OwnerUserId = user.Id, + Pictures = new TrackPictures { OriginalName = "old.webp" }, + Audio = new TrackAudio() + }; + await database.Tracks.AddAsync(track, Ct); + await database.SaveChangesAsync(Ct); + return track; + } + + public sealed record AlwaysFailsArguments; + + public sealed class AlwaysFailsActivity : IExecuteActivity + { + public const string ExecuteEndpointName = "always-fails"; + + public Task Execute(ExecuteContext context) => + throw new InvalidOperationException("Forced failure"); + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/Persistence/AuditableEntityInterceptorTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Persistence/AuditableEntityInterceptorTests.cs index a846be2..db7ce42 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Persistence/AuditableEntityInterceptorTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Persistence/AuditableEntityInterceptorTests.cs @@ -3,51 +3,52 @@ 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 name = "audit-user"; - var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; - await database.Users.AddAsync(user, TestContext.Current.CancellationToken); - await database.SaveChangesAsync(TestContext.Current.CancellationToken); + var name = "audit-user"; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; + 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 name = "audit-user-2"; - var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; - await database.Users.AddAsync(user, TestContext.Current.CancellationToken); - await database.SaveChangesAsync(TestContext.Current.CancellationToken); - var originalCreatedAt = user.CreatedAt; + var name = "audit-user-2"; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = name, NormalizedName = name.ToUpperInvariant() }; + 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}"); - await using var reader = fixture.CreateDatabase(); - var stored = await reader.Users.SingleAsync(u => u.Id == user.Id, TestContext.Current.CancellationToken); - Assert.True(stored.UpdatedAt > stored.CreatedAt, - $"stored-created-at={stored.CreatedAt:O}, stored-updated-at={stored.UpdatedAt:O}"); + await using var reader = fixture.CreateDatabase(); + var stored = await reader.Users.SingleAsync(u => u.Id == user.Id, TestContext.Current.CancellationToken); + Assert.True(stored.UpdatedAt > stored.CreatedAt, + $"stored-created-at={stored.CreatedAt:O}, stored-updated-at={stored.UpdatedAt:O}"); + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs index 57c7c26..3265b9e 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs @@ -4,104 +4,105 @@ 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_TrackWithOwnedTypes_RoundTripsThroughPostgres() + [Collection(InfrastructureCollection.Name)] + public sealed class DatabaseTests(InfrastructureTestFixture fixture) { - await using var database = fixture.CreateDatabase(); - - var userName = "roundtrip-user"; - var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() }; - await database.Users.AddAsync(user, TestContext.Current.CancellationToken); - - var trackTitle = "Round Trip Track"; - var track = new Track + [Fact] + public async Task SaveChangesAsync_TrackWithOwnedTypes_RoundTripsThroughPostgres() { - Title = trackTitle, - NormalizedTitle = trackTitle.ToUpperInvariant(), - 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.Tracks.AddAsync(track, TestContext.Current.CancellationToken); - await database.SaveChangesAsync(TestContext.Current.CancellationToken); + await using var database = fixture.CreateDatabase(); - await using var reloaded = fixture.CreateDatabase(); - var stored = await reloaded.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken); + var userName = "roundtrip-user"; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() }; + await database.Users.AddAsync(user, TestContext.Current.CancellationToken); - Assert.Equal(trackTitle, stored.Title); - Assert.Equal("cover.webp", stored.Pictures.OriginalName); - Assert.Equal("song.mp3", stored.Audio.OriginalName); - Assert.Equal(user.Id, stored.OwnerUserId); - } + var trackTitle = "Round Trip Track"; + var track = new Track + { + Title = trackTitle, + NormalizedTitle = trackTitle.ToUpperInvariant(), + 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.Tracks.AddAsync(track, TestContext.Current.CancellationToken); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); - [Fact] - public async Task SaveChangesAsync_TrackWithTags_RoundTripsThroughNativePostgresEnum() - { - await using var database = fixture.CreateDatabase(); + await using var reloaded = fixture.CreateDatabase(); + var stored = await reloaded.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken); - var userName = "tags-roundtrip-user"; - var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() }; - var track = BuildTrack(user, $"tags-{Guid.NewGuid():N}"); - track.Tags = - [ - new TrackTag { TrackId = track.Id, Tag = Genre.Pop }, - new TrackTag { TrackId = track.Id, Tag = Genre.Rock } - ]; + Assert.Equal(trackTitle, stored.Title); + Assert.Equal("cover.webp", stored.Pictures.OriginalName); + Assert.Equal("song.mp3", stored.Audio.OriginalName); + Assert.Equal(user.Id, stored.OwnerUserId); + } - await database.AddRangeAsync(user, track); - await database.SaveChangesAsync(TestContext.Current.CancellationToken); - - await using var reloaded = fixture.CreateDatabase(); - var stored = await reloaded.Tracks.AsNoTracking() - .Include(t => t.Tags) - .SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken); - - Assert.Equal([Genre.Pop, Genre.Rock], stored.Tags.Select(t => t.Tag).OrderBy(t => t)); - } - - [Fact] - public async Task AlbumHasTracks_DeletingAlbum_CascadesToTheLinkRows() - { - await using var database = fixture.CreateDatabase(); - - var userName = "cascade-user"; - var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() }; - var albumTitle = "Cascade Album"; - var album = new Album { Title = albumTitle, NormalizedTitle = albumTitle.ToUpperInvariant(), OwnerUserId = user.Id }; - var track = BuildTrack(user, $"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.Albums.SingleAsync(a => a.Id == album.Id, TestContext.Current.CancellationToken); - deleter.Albums.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.Tracks.AnyAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken)); - } - - private static Track BuildTrack(User owner, string title) - { - return new Track + [Fact] + public async Task SaveChangesAsync_TrackWithTags_RoundTripsThroughNativePostgresEnum() { - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - DurationSeconds = 100, - OwnerUserId = owner.Id, - Owner = owner, - Pictures = new TrackPictures(), - Audio = new TrackAudio() - }; + await using var database = fixture.CreateDatabase(); + + var userName = "tags-roundtrip-user"; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() }; + var track = BuildTrack(user, $"tags-{Guid.NewGuid():N}"); + track.Tags = + [ + new TrackTag { TrackId = track.Id, Tag = Genre.Pop }, + new TrackTag { TrackId = track.Id, Tag = Genre.Rock } + ]; + + await database.AddRangeAsync(user, track); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + + await using var reloaded = fixture.CreateDatabase(); + var stored = await reloaded.Tracks.AsNoTracking() + .Include(t => t.Tags) + .SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken); + + Assert.Equal([Genre.Pop, Genre.Rock], stored.Tags.Select(t => t.Tag).OrderBy(t => t)); + } + + [Fact] + public async Task AlbumHasTracks_DeletingAlbum_CascadesToTheLinkRows() + { + await using var database = fixture.CreateDatabase(); + + var userName = "cascade-user"; + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() }; + var albumTitle = "Cascade Album"; + var album = new Album { Title = albumTitle, NormalizedTitle = albumTitle.ToUpperInvariant(), OwnerUserId = user.Id }; + var track = BuildTrack(user, $"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.Albums.SingleAsync(a => a.Id == album.Id, TestContext.Current.CancellationToken); + deleter.Albums.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.Tracks.AnyAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken)); + } + + private static Track BuildTrack(User owner, string title) + { + return new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + DurationSeconds = 100, + OwnerUserId = owner.Id, + Owner = owner, + Pictures = new TrackPictures(), + Audio = new TrackAudio() + }; + } } } 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/StorageServiceTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs index f96cbba..f58fd78 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs @@ -7,130 +7,131 @@ 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 StorageService service = null!; - - public ValueTask InitializeAsync() + [Collection(InfrastructureCollection.Name)] + public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAsyncLifetime { - var storageConfiguration = new InfrastructureStorageConfiguration + private StorageService service = null!; + + public ValueTask InitializeAsync() { - Address = fixture.S3ServiceUrl, - AccessKey = InfrastructureTestFixture.S3AccessKey, - SecretAccessKey = InfrastructureTestFixture.S3SecretKey, - ForcePathStyle = true, - UseHttp = true, - }; - service = new StorageService(fixture.CreateS3Client(), NullLogger.Instance, storageConfiguration); - return ValueTask.CompletedTask; - } + var storageConfiguration = new InfrastructureStorageConfiguration + { + Address = fixture.S3ServiceUrl, + AccessKey = InfrastructureTestFixture.S3AccessKey, + SecretAccessKey = InfrastructureTestFixture.S3SecretKey, + ForcePathStyle = true, + UseHttp = true, + }; + service = new StorageService(fixture.CreateS3Client(), NullLogger.Instance, storageConfiguration); + return ValueTask.CompletedTask; + } - public ValueTask DisposeAsync() => ValueTask.CompletedTask; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; - [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); - Assert.NotNull(stream); - 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 GetFileAsync_MissingObject_ReturnsNull() - { - var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, $"tests/{Guid.NewGuid():N}/missing.txt", TestContext.Current.CancellationToken); - - Assert.Null(stream); - } - - [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 GetUploadUrlAsync_PresignedPut_CanActuallyUploadTheObject() - { - var key = $"tests/{Guid.NewGuid():N}/presigned.txt"; - - var uploadUrl = await service.GetUploadUrlAsync( - InfrastructureTestFixture.S3Bucket, key, "text/plain", TimeSpan.FromMinutes(5), cancellationToken: TestContext.Current.CancellationToken); - - using var httpClient = new HttpClient(); - using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl) + [Fact] + public async Task UploadThenGetFile_RoundTripsTheSameBytes() { - Content = new ByteArrayContent(Encoding.UTF8.GetBytes("presigned content")) - }; - request.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); - 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); + Assert.NotNull(stream); + 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 GetFileAsync_MissingObject_ReturnsNull() + { + var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, $"tests/{Guid.NewGuid():N}/missing.txt", TestContext.Current.CancellationToken); + + Assert.Null(stream); + } + + [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 GetUploadUrlAsync_PresignedPut_CanActuallyUploadTheObject() + { + var key = $"tests/{Guid.NewGuid():N}/presigned.txt"; + + var uploadUrl = await service.GetUploadUrlAsync( + InfrastructureTestFixture.S3Bucket, key, "text/plain", TimeSpan.FromMinutes(5), cancellationToken: TestContext.Current.CancellationToken); + + using var httpClient = new HttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl) + { + Content = new ByteArrayContent(Encoding.UTF8.GetBytes("presigned content")) + }; + request.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); + 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 155d645..d16e298 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/StreamTicketServiceTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/StreamTicketServiceTests.cs @@ -5,104 +5,105 @@ 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 void IssueTicket_AnonymousUser_OmitsTheSubClaim() - { - using var service = CreateService(); - - var ticket = service.IssueTicket(userId: null, keyPrefix: "Tracks/ProcessedAudios/abc/"); - - var handler = new JsonWebTokenHandler(); - var jwt = handler.ReadJsonWebToken(ticket.Token); - Assert.DoesNotContain(jwt.Claims, c => c.Type == "sub"); - Assert.Equal("Tracks/ProcessedAudios/abc/", jwt.GetClaim("prefix").Value); - } - - [Fact] - public void IssueTicket_WithMaxBytes_IncludesTheMaxBytesClaim() - { - using var service = CreateService(); - - var ticket = service.IssueTicket(userId: null, keyPrefix: "Tracks/ProcessedAudios/abc/", maxBytes: 480_000); - - var handler = new JsonWebTokenHandler(); - var jwt = handler.ReadJsonWebToken(ticket.Token); - Assert.Equal("480000", jwt.GetClaim("maxBytes").Value); - } - - [Fact] - public void IssueTicket_WithoutMaxBytes_OmitsTheMaxBytesClaim() - { - using var service = CreateService(); - - var ticket = service.IssueTicket(userId: 1, keyPrefix: "Tracks/ProcessedAudios/abc/"); - - var handler = new JsonWebTokenHandler(); - var jwt = handler.ReadJsonWebToken(ticket.Token); - Assert.DoesNotContain(jwt.Claims, c => c.Type == "maxBytes"); - } - - [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 void IssueTicket_AnonymousUser_OmitsTheSubClaim() + { + using var service = CreateService(); + + var ticket = service.IssueTicket(userId: null, keyPrefix: "Tracks/ProcessedAudios/abc/"); + + var handler = new JsonWebTokenHandler(); + var jwt = handler.ReadJsonWebToken(ticket.Token); + Assert.DoesNotContain(jwt.Claims, c => c.Type == "sub"); + Assert.Equal("Tracks/ProcessedAudios/abc/", jwt.GetClaim("prefix").Value); + } + + [Fact] + public void IssueTicket_WithMaxBytes_IncludesTheMaxBytesClaim() + { + using var service = CreateService(); + + var ticket = service.IssueTicket(userId: null, keyPrefix: "Tracks/ProcessedAudios/abc/", maxBytes: 480_000); + + var handler = new JsonWebTokenHandler(); + var jwt = handler.ReadJsonWebToken(ticket.Token); + Assert.Equal("480000", jwt.GetClaim("maxBytes").Value); + } + + [Fact] + public void IssueTicket_WithoutMaxBytes_OmitsTheMaxBytesClaim() + { + using var service = CreateService(); + + var ticket = service.IssueTicket(userId: 1, keyPrefix: "Tracks/ProcessedAudios/abc/"); + + var handler = new JsonWebTokenHandler(); + var jwt = handler.ReadJsonWebToken(ticket.Token); + Assert.DoesNotContain(jwt.Claims, c => c.Type == "maxBytes"); + } + + [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 7ccc578..622507f 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs @@ -10,97 +10,98 @@ using Musify.Infrastructure.Persistence; using Testcontainers.PostgreSql; using Xunit; -namespace Musify.Infrastructure.Tests.TestSupport; - -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 static readonly string[] S3Actions = ["Admin", "Read", "Write"]; - - 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() + 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(); + private static readonly string[] S3Actions = ["Admin", "Read", "Write"]; - using var s3 = CreateS3Client(); - await s3.PutBucketAsync(S3Bucket); - } - - public async ValueTask DisposeAsync() - { - await postgres.DisposeAsync(); - await seaweedFs.DisposeAsync(); - } - - 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(); - 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(); + + using var s3 = CreateS3Client(); + await s3.PutBucketAsync(S3Bucket); + } + + public async ValueTask DisposeAsync() + { + await postgres.DisposeAsync(); + await seaweedFs.DisposeAsync(); + } + + public Database CreateDatabase() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection([new("Database:ConnectionString", ConnectionString)]) + .Build(); + + var services = new ServiceCollection(); + services.AddDatabase(configuration); + return services.BuildServiceProvider().GetRequiredService(); + } + + public IAmazonS3 CreateS3Client() => + new AmazonS3Client(S3AccessKey, S3SecretKey, new AmazonS3Config { - new - { - name = "musify-test", - credentials = new[] { new { accessKey = S3AccessKey, secretKey = S3SecretKey } }, - actions = S3Actions - } - } - }); + ServiceURL = S3ServiceUrl, + ForcePathStyle = true, + UseHttp = true, + }); - return $""" + private static string BuildSeaweedFsStartupScript() + { + var identity = JsonSerializer.Serialize(new + { + identities = new[] + { + new + { + name = "musify-test", + credentials = new[] { new { accessKey = S3AccessKey, secretKey = S3SecretKey } }, + actions = S3Actions + } + } + }); + + 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 d96b479..3bcd9fc 100644 --- a/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs +++ b/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs @@ -6,159 +6,160 @@ 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(new StreamTicketValidationConfiguration - { - 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, long? maxBytes = null) - { - var claims = new Dictionary { ["prefix"] = prefix }; - if (maxBytes != null) - claims["maxBytes"] = maxBytes.Value.ToString(CultureInfo.InvariantCulture); + public TicketValidatorTests() => File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem()); - var descriptor = new SecurityTokenDescriptor + public void Dispose() { - Issuer = issuer, - Audience = audience, - NotBefore = notBefore ?? DateTime.UtcNow.AddMinutes(-1), - Expires = expires ?? DateTime.UtcNow.AddMinutes(5), - Claims = claims, - 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 result = await CreateValidator().ValidateTicketAsync(token); - - Assert.True(result.IsValid); - Assert.Equal("Tracks/ProcessedAudios/xyz/", result.Prefix); - Assert.Null(result.MaxBytes); - } - - [Fact] - public async Task ValidateTicketAsync_TokenWithMaxBytesClaim_ReturnsIt() - { - var token = IssueToken(maxBytes: 480_000); - - var result = await CreateValidator().ValidateTicketAsync(token); - - Assert.True(result.IsValid); - Assert.Equal(480_000, result.MaxBytes); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" ")] - public async Task ValidateTicketAsync_BlankToken_ReturnsInvalid(string? token) - { - var result = await CreateValidator().ValidateTicketAsync(token); - - Assert.False(result.IsValid); - Assert.Equal(string.Empty, result.Prefix); - } - - [Fact] - public async Task ValidateTicketAsync_MalformedToken_ReturnsInvalid() - { - var result = await CreateValidator().ValidateTicketAsync("not-a-real-jwt"); - - Assert.False(result.IsValid); - } - - [Fact] - public async Task ValidateTicketAsync_ExpiredToken_ReturnsInvalid() - { - var token = IssueToken(expires: DateTime.UtcNow.AddMinutes(-40), notBefore: DateTime.UtcNow.AddMinutes(-50)); - - var result = await CreateValidator().ValidateTicketAsync(token); - - Assert.False(result.IsValid); - } - - [Fact] - public async Task ValidateTicketAsync_WrongIssuer_ReturnsInvalid() - { - var token = IssueToken(issuer: "someone-else"); - - var result = await CreateValidator().ValidateTicketAsync(token); - - Assert.False(result.IsValid); - } - - [Fact] - public async Task ValidateTicketAsync_WrongAudience_ReturnsInvalid() - { - var token = IssueToken(audience: "someone-else"); - - var result = await CreateValidator().ValidateTicketAsync(token); - - Assert.False(result.IsValid); - } - - [Fact] - public async Task ValidateTicketAsync_SignedWithADifferentKey_ReturnsInvalid() - { - using var otherKey = RSA.Create(2048); - var descriptor = new SecurityTokenDescriptor + private TicketValidator CreateValidator() => new(new StreamTicketValidationConfiguration { - 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 result = await CreateValidator().ValidateTicketAsync(token); - - Assert.False(result.IsValid); - } - - [Fact] - public async Task ValidateTicketAsync_MissingPrefixClaim_ReturnsInvalid() - { - var descriptor = new SecurityTokenDescriptor + private string IssueToken(string prefix = "Tracks/ProcessedAudios/abc/", string issuer = Issuer, string audience = Audience, + DateTime? expires = null, DateTime? notBefore = null, long? maxBytes = 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 claims = new Dictionary { ["prefix"] = prefix }; + if (maxBytes != null) + claims["maxBytes"] = maxBytes.Value.ToString(CultureInfo.InvariantCulture); - var result = await CreateValidator().ValidateTicketAsync(token); + var descriptor = new SecurityTokenDescriptor + { + Issuer = issuer, + Audience = audience, + NotBefore = notBefore ?? DateTime.UtcNow.AddMinutes(-1), + Expires = expires ?? DateTime.UtcNow.AddMinutes(5), + Claims = claims, + SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256), + }; + return tokenHandler.CreateToken(descriptor); + } - Assert.False(result.IsValid); + [Fact] + public async Task ValidateTicketAsync_ValidToken_ReturnsThePrefixClaim() + { + var token = IssueToken(prefix: "Tracks/ProcessedAudios/xyz/"); + + var result = await CreateValidator().ValidateTicketAsync(token); + + Assert.True(result.IsValid); + Assert.Equal("Tracks/ProcessedAudios/xyz/", result.Prefix); + Assert.Null(result.MaxBytes); + } + + [Fact] + public async Task ValidateTicketAsync_TokenWithMaxBytesClaim_ReturnsIt() + { + var token = IssueToken(maxBytes: 480_000); + + var result = await CreateValidator().ValidateTicketAsync(token); + + Assert.True(result.IsValid); + Assert.Equal(480_000, result.MaxBytes); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task ValidateTicketAsync_BlankToken_ReturnsInvalid(string? token) + { + var result = await CreateValidator().ValidateTicketAsync(token); + + Assert.False(result.IsValid); + Assert.Equal(string.Empty, result.Prefix); + } + + [Fact] + public async Task ValidateTicketAsync_MalformedToken_ReturnsInvalid() + { + var result = await CreateValidator().ValidateTicketAsync("not-a-real-jwt"); + + Assert.False(result.IsValid); + } + + [Fact] + public async Task ValidateTicketAsync_ExpiredToken_ReturnsInvalid() + { + var token = IssueToken(expires: DateTime.UtcNow.AddMinutes(-40), notBefore: DateTime.UtcNow.AddMinutes(-50)); + + var result = await CreateValidator().ValidateTicketAsync(token); + + Assert.False(result.IsValid); + } + + [Fact] + public async Task ValidateTicketAsync_WrongIssuer_ReturnsInvalid() + { + var token = IssueToken(issuer: "someone-else"); + + var result = await CreateValidator().ValidateTicketAsync(token); + + Assert.False(result.IsValid); + } + + [Fact] + public async Task ValidateTicketAsync_WrongAudience_ReturnsInvalid() + { + var token = IssueToken(audience: "someone-else"); + + var result = await CreateValidator().ValidateTicketAsync(token); + + Assert.False(result.IsValid); + } + + [Fact] + public async Task ValidateTicketAsync_SignedWithADifferentKey_ReturnsInvalid() + { + 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 result = await CreateValidator().ValidateTicketAsync(token); + + Assert.False(result.IsValid); + } + + [Fact] + public async Task ValidateTicketAsync_MissingPrefixClaim_ReturnsInvalid() + { + 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 result = await CreateValidator().ValidateTicketAsync(token); + + Assert.False(result.IsValid); + } } } diff --git a/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs b/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs index 96fe153..248e480 100644 --- a/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs +++ b/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs @@ -9,244 +9,245 @@ 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 StreamTicketValidationConfiguration options; - - public TicketValidationMiddlewareTests() + public sealed class TicketValidationMiddlewareTests : IDisposable { - File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem()); - options = new StreamTicketValidationConfiguration + private const string Prefix = "Tracks/ProcessedAudios/abc"; + + private readonly string publicKeyPath = Path.GetTempFileName(); + private readonly RSA rsa = RSA.Create(2048); + private readonly StreamTicketValidationConfiguration options; + + public TicketValidationMiddlewareTests() { - PublicKeyPath = publicKeyPath, - Audience = "media-gateway", - Issuer = "musify-webapi", - }; - } + File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem()); + options = new StreamTicketValidationConfiguration + { + PublicKeyPath = publicKeyPath, + Audience = "media-gateway", + Issuer = "musify-webapi", + }; + } - public void Dispose() - { - rsa.Dispose(); - File.Delete(publicKeyPath); - } - - private string IssueValidToken(string prefix = Prefix, long? maxBytes = null) - { - var claims = new Dictionary { ["prefix"] = prefix }; - if (maxBytes != null) - claims["maxBytes"] = maxBytes.Value.ToString(CultureInfo.InvariantCulture); - - return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor + public void Dispose() { - Issuer = options.Issuer, - Audience = options.Audience, - NotBefore = DateTime.UtcNow.AddMinutes(-1), - Expires = DateTime.UtcNow.AddMinutes(5), - Claims = claims, - SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256), - }); - } + rsa.Dispose(); + File.Delete(publicKeyPath); + } - private (TicketValidationMiddleware Middleware, Func WasNextCalled) CreateMiddleware() - { - var nextCalled = false; - RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; }; - var validator = new TicketValidator(options); - var middleware = new TicketValidationMiddleware(next, validator, options, NullLogger.Instance); - return (middleware, () => nextCalled); - } + private string IssueValidToken(string prefix = Prefix, long? maxBytes = null) + { + var claims = new Dictionary { ["prefix"] = prefix }; + if (maxBytes != null) + claims["maxBytes"] = maxBytes.Value.ToString(CultureInfo.InvariantCulture); - [Fact] - public async Task InvokeAsync_PathOutsideMediaPrefix_PassesThroughWithoutValidatingATicket() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = "/health"; + return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor + { + Issuer = options.Issuer, + Audience = options.Audience, + NotBefore = DateTime.UtcNow.AddMinutes(-1), + Expires = DateTime.UtcNow.AddMinutes(5), + Claims = claims, + SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256), + }); + } - await middleware.InvokeAsync(context); + private (TicketValidationMiddleware Middleware, Func WasNextCalled) CreateMiddleware() + { + var nextCalled = false; + RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; }; + var validator = new TicketValidator(options); + var middleware = new TicketValidationMiddleware(next, validator, options, NullLogger.Instance); + return (middleware, () => nextCalled); + } - Assert.True(wasNextCalled()); - Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); - } + [Fact] + public async Task InvokeAsync_PathOutsideMediaPrefix_PassesThroughWithoutValidatingATicket() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = "/health"; - [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.True(wasNextCalled()); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + } - Assert.False(wasNextCalled()); - Assert.Equal(StatusCodes.Status401Unauthorized, 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_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.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + } - Assert.True(wasNextCalled()); - Assert.Equal("keep=1", context.Request.QueryString.Value?.TrimStart('?')); - } + [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_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.Equal("keep=1", context.Request.QueryString.Value?.TrimStart('?')); + } - Assert.True(wasNextCalled()); - } + [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_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.True(wasNextCalled()); + } - Assert.False(wasNextCalled()); - Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode); - } + [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()}"); - [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.Status403Forbidden, context.Response.StatusCode); + } - Assert.False(wasNextCalled()); - Assert.Equal(StatusCodes.Status400BadRequest, 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()}"); - [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.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); + } - Assert.True(wasNextCalled()); - } + [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_TicketWithMaxBytes_NoRangeRequested_SynthesizesABoundedRange() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = $"/media/{Prefix}/audio.m4a"; - context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); + await middleware.InvokeAsync(context); - await middleware.InvokeAsync(context); + Assert.True(wasNextCalled()); + } - Assert.True(wasNextCalled()); - Assert.Equal("bytes=0-479999", context.Request.Headers.Range.ToString()); - } + [Fact] + public async Task InvokeAsync_TicketWithMaxBytes_NoRangeRequested_SynthesizesABoundedRange() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); - [Fact] - public async Task InvokeAsync_TicketWithMaxBytes_RangeWithinCap_ClampsTheEndOnly() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = $"/media/{Prefix}/audio.m4a"; - context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); - context.Request.Headers.Range = "bytes=1000-"; + await middleware.InvokeAsync(context); - await middleware.InvokeAsync(context); + Assert.True(wasNextCalled()); + Assert.Equal("bytes=0-479999", context.Request.Headers.Range.ToString()); + } - Assert.True(wasNextCalled()); - Assert.Equal("bytes=1000-479999", context.Request.Headers.Range.ToString()); - } + [Fact] + public async Task InvokeAsync_TicketWithMaxBytes_RangeWithinCap_ClampsTheEndOnly() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); + context.Request.Headers.Range = "bytes=1000-"; - [Fact] - public async Task InvokeAsync_TicketWithMaxBytes_RangeStartsAtOrPastTheCap_ReturnsRangeNotSatisfiable() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = $"/media/{Prefix}/audio.m4a"; - context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); - context.Request.Headers.Range = "bytes=480000-"; + await middleware.InvokeAsync(context); - await middleware.InvokeAsync(context); + Assert.True(wasNextCalled()); + Assert.Equal("bytes=1000-479999", context.Request.Headers.Range.ToString()); + } - Assert.False(wasNextCalled()); - Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, context.Response.StatusCode); - } + [Fact] + public async Task InvokeAsync_TicketWithMaxBytes_RangeStartsAtOrPastTheCap_ReturnsRangeNotSatisfiable() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); + context.Request.Headers.Range = "bytes=480000-"; - [Fact] - public async Task InvokeAsync_TicketWithoutMaxBytes_LeavesTheRequestedRangeUntouched() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = $"/media/{Prefix}/audio.m4a"; - context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); - context.Request.Headers.Range = "bytes=1000-2000"; + await middleware.InvokeAsync(context); - await middleware.InvokeAsync(context); + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, context.Response.StatusCode); + } - Assert.True(wasNextCalled()); - Assert.Equal("bytes=1000-2000", context.Request.Headers.Range.ToString()); - } + [Fact] + public async Task InvokeAsync_TicketWithoutMaxBytes_LeavesTheRequestedRangeUntouched() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); + context.Request.Headers.Range = "bytes=1000-2000"; - [Theory] - [InlineData("bytes=0-10,20-30")] - [InlineData("garbage")] - [InlineData("items=0-10")] - public async Task InvokeAsync_TicketWithMaxBytes_MultiOrInvalidRange_ReplacesItWithBoundedRange(string requestedRange) - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = $"/media/{Prefix}/audio.m4a"; - context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); - context.Request.Headers.Range = requestedRange; + await middleware.InvokeAsync(context); - await middleware.InvokeAsync(context); + Assert.True(wasNextCalled()); + Assert.Equal("bytes=1000-2000", context.Request.Headers.Range.ToString()); + } - Assert.True(wasNextCalled()); - Assert.Equal("bytes=0-479999", context.Request.Headers.Range.ToString()); - } + [Theory] + [InlineData("bytes=0-10,20-30")] + [InlineData("garbage")] + [InlineData("items=0-10")] + public async Task InvokeAsync_TicketWithMaxBytes_MultiOrInvalidRange_ReplacesItWithBoundedRange(string requestedRange) + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); + context.Request.Headers.Range = requestedRange; - [Fact] - public async Task InvokeAsync_TicketWithMaxBytes_SuffixRange_ReturnsRangeNotSatisfiable() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = $"/media/{Prefix}/audio.m4a"; - context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); - context.Request.Headers.Range = "bytes=-500"; + await middleware.InvokeAsync(context); - await middleware.InvokeAsync(context); + Assert.True(wasNextCalled()); + Assert.Equal("bytes=0-479999", context.Request.Headers.Range.ToString()); + } - Assert.False(wasNextCalled()); - Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, context.Response.StatusCode); + [Fact] + public async Task InvokeAsync_TicketWithMaxBytes_SuffixRange_ReturnsRangeNotSatisfiable() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); + context.Request.Headers.Range = "bytes=-500"; + + await middleware.InvokeAsync(context); + + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, context.Response.StatusCode); + } } } diff --git a/backend/docs/backend-refactor-handoff.md b/backend/docs/backend-refactor-handoff.md new file mode 100644 index 0000000..4ab7eea --- /dev/null +++ b/backend/docs/backend-refactor-handoff.md @@ -0,0 +1,235 @@ +# Handoff: backend refactor, parts P12 to P15 + +> For the next AI. Read this file first, then `backend/docs/backend-refactor-plan.md` +> (section A, B, and the parts P12–P15, plus the Log in section D). Both files are +> **untracked on purpose**: never commit them. + +## 1. Where things stand + +- Worktree: `C:\Users\XShin\OneDrive\Documentos\Musify\MusifyM\.claude\worktrees\backend-refactor-plan-9b4465` +- Branch: `claude/backend-refactor-plan-9b4465` (base `master` at `87c3f97`). Nothing is pushed. +- Done and committed: **P0 to P11** (one commit per part, see `git log --oneline`). +- Left: **P12** (API layer), **P13** (Worker and Gateway hosts), **P14** (tests), **P15** (final sweep and docs). +- State at the last commit (`405df08`): build with 0 warnings, 365 tests green + (Infrastructure tests need Docker), `npm run check` in `web-player/` clean. +- The Log (section D of the plan) already has notes for P0–P11 and is the source of truth + for decisions taken. Update it (checkbox, date, notes) for every part you finish. + +### Deviations from the plan already taken (do not undo) + +- `IOwnedEntity` lives in `Domain/Abstractions` and has no `Id`; `IHasLifeCycle` provides it. + `FindOwnedAsync` requires both and treats non-active entities as NotFound. +- List query records keep `int PageNumber, int PageSize`; the handler builds a `PageRequest`. + The API binds them through `Api/Models/PageQuery` (camelCase names) + `PageQueryValidator`. +- Events and `Publish*Arguments` carry `ImageSizes Sizes`. +- Cover endpoint helper `CoverEndpoint.Stream` already exists (this was P12.2). +- Mix titles were replaced by `MixKind`; the web-player renders the text (`src/lib/data/mixes.ts`). +- The two migrations planned in P4 were merged into `DropUploadTableAndTrackAudioRetryColumns`. +- Adding a duplicate track to a playlist or album is now a silent success (bulk endpoints). +- Hangfire cron has 1-minute resolution, so job intervals in seconds are rounded up to minutes. +- Pending inside P11: log-and-rethrow still present in `MassTransit/Activities/Files/*` + (Copy, Download, Remove, Transfer, Upload). Clean it in P15 or when touching them. + +## 2. Rules the user gave (follow them exactly) + +1. **Language of the conversation: Spanish.** Talk to the user in Spanish. +2. **Commit messages: English, only about the code that changed.** Never mention the plan, + part numbers (P0, P1...), the Log, or "step X". One commit per part, imperative mood, + e.g. `Simplify infrastructure services and move all recurring jobs to Hangfire`. + (This is also saved in the memory file `feedback_commit_messages.md`.) +3. **Never include the plan documents in commits.** `backend/docs/backend-refactor-plan.md` + and `backend/docs/backend-refactor-handoff.md` stay untracked. Also never commit the + temporary test `backend/Tests/Musify.Api.Tests/Endpoints/ZzOpenApiDumpTests.cs`. + Stage with `git add backend web-player` and then + `git reset -q backend/docs backend/Tests/Musify.Api.Tests/Endpoints/ZzOpenApiDumpTests.cs`. +4. **Do the plan in parts, one part per commit, in order.** Do not mix parts. Never mix + formatting with behaviour. +5. **When the user says to stop at a point, stop there** (the last message was "para en esta + parte cuando termines", so work stopped after P11). Only continue when asked. +6. Follow the plan's own rules: read section A first; create a shared helper from section B + yourself if it does not exist yet; keep the Log and the findings index (section E) up to date; + a contract change needs the web-player regeneration (section 4 below); an EF model change + needs a migration and `TestDatabase` kept in sync; a MassTransit message change must be + noted in the Log ("drain queues before deploying"). +7. Pronouns: use they/them for anyone whose pronouns are not stated. +8. Outward-facing or hard-to-reverse actions (push, PR, deleting things) need explicit approval. + Nothing has been pushed; do not push or open a PR unless asked. +9. Report faithfully: if a test fails or something was skipped or unverifiable + (e.g. manual end-to-end runs), say so in the Log and in the final message. + +## 3. Coding conventions (from the plan, section B.2, keep applying them) + +- File-scoped namespaces, usings outside, sorted (`.editorconfig` enforces it; the build treats + warnings as errors, including unused usings IDE0005). +- Private fields camelCase without underscore; constants PascalCase; explicit accessibility. +- Application: `/.cs` holds record + handler; responses in `Responses/`. +- Errors via `AppErrors` (`NotFound`, `Forbidden` = 403 for "not owner", `Conflict`); + `Unauthorized` (401) only for "not signed in". Success value is `Result.Success`. +- No try/catch around `SaveChangesAsync`/`PublishAsync` in handlers (outbox: publish then save + is atomic). Infrastructure either handles or rethrows, never log-and-rethrow. +- Handlers that cannot fail return plain types; lists are paginated, ordered with `Id` tiebreak, + public reads return only `LifeCycleStatus.Active`. +- Text: trim stored names; normalized columns use `TextNormalizer.Normalize`. +- `ArgumentNullException.ThrowIfNull(x)` without `nameof`. +- User-facing strings in English (the web-player owns translation). +- Naming: `PlayList` everywhere; config POCOs end in `Configuration`; injected services are + named after their type (`storageService`). + +## 4. Practical procedures (learned the hard way) + +Run everything from `backend/` unless stated. + +### Build, format fixes, tests + +```bash +dotnet build Musify.slnx +dotnet test Musify.slnx +``` + +- Do **not** chain `dotnet test ... | grep && commit`: a failing run can look successful. + Read the totals (`total:`, `succeeded:`) before committing. +- Infrastructure and Api tests need Docker Desktop running. If `docker info` fails, start + `C:\Users\XShin\AppData\Local\Programs\DockerDesktop\Docker Desktop.exe` and wait. +- Unused-using errors (IDE0005) are common after edits. A helper script removes them: + `python /fixusings.py` (loops `dotnet build`, deletes the flagged using lines). + If the scratchpad is gone, do it by hand or with `dotnet format`. + +### Editing files from the shell (important) + +- The Bash tool **fails to parse** many commands that combine several heredocs, inline + `python - <<'EOF'` with backslashes/quotes, or big C# blocks ("unexpected EOF while looking + for matching `'`"). When that happens nothing runs. Use the **Write tool** for files and put + Python edit scripts in a file (in the scratchpad) and run `python that_file.py`. +- Working tree files use LF while git has autocrlf on; warnings like "LF will be replaced by + CRLF" are noise. Redirect stderr (`2>/dev/null`) on `git add/commit`. Python edit helpers + should read/write with `newline=''` and preserve CRLF if present. + +### EF migrations + +```bash +export Database__ConnectionString="Host=localhost;Database=x;Username=x;Password=x" +dotnet ef migrations add --project Core/Musify.Infrastructure --startup-project Core/Musify.Infrastructure +``` + +- `dotnet ef migrations remove` needs a DB connection and fails offline: delete the migration + files by hand and `git checkout` the snapshot, then re-add. +- To verify a model refactor keeps the schema: add a migration named `Check`, confirm `Up` + is empty, delete its two files (keep snapshot changes if they only rename types). +- Hand-edit migrations when data must be preserved (see `MixKind`, `PlayListTrackUnique`). +- `Application.Tests/TestSupport/TestDatabase.cs` mirrors part of `Database.OnModelCreating` + (P14.3 will extract `ModelConfiguration.Apply`). + +### Web-player contract regeneration (when an HTTP contract changes) + +1. `web-player/node_modules` is a junction to the main checkout's `node_modules` + (git-ignored). If missing: + `cmd //c "mklink /J web-player\node_modules ..\..\..\web-player\node_modules"` from the worktree root. +2. The OpenAPI document is dumped by the temporary test `ZzOpenApiDumpTests` (untracked; it + returns early unless env `OPENAPI_OUT` is set and runs the API in Development). Script used: + +```bash +cd backend +export ASPNETCORE_ENVIRONMENT=Development OPENAPI_OUT="/openapi.json" +dotnet test Tests/Musify.Api.Tests --filter "FullyQualifiedName~ZzOpenApiDump" +cd ../web-player +npx -y openapi-typescript@7 "/openapi.json" -o src/lib/api/schema.d.ts +npm run check +``` + + If the test file is gone, recreate it: it fetches `/openapi/v1.json` through + `fixture.CreateAnonymousClient()` and writes it to `OPENAPI_OUT`. + P12.1 changes `WebApplication.CreateBuilder(args)`; keep the dump working afterwards. +3. Fix web-player call sites, run `npx prettier --write` on touched files, then `npm run check`. +4. Delete `ZzOpenApiDumpTests.cs` at the very end (P15). + +## 5. What is left, part by part + +### P12: API layer (plan section C, P12) + +1. `Program.cs`: `WebApplication.CreateBuilder(args)`, sorted usings, CORS constant at top. +2. Cover endpoints: **already done** with `CoverEndpoint.Stream`. +3. Routes: resource id is `{id}`, sub-resources `{trackId}`; Guid routes use `:guid`, + user ids `:long`. Playlist routes keep `/playlists`. (Contract change: regenerate.) +4. Every endpoint returns `IResult` through `ToHttpResult` (some still return raw types, + e.g. `GetListeningHistory`, `IsFollowing`); use `Results.Ok` where the handler cannot fail. +5. DTOs: drop the `New...` prefix (`UpdateAlbumRequest(Title, Description, ReleaseYear, + PictureIntentId)`; same for playlists). Decision recommended in the plan: rename. +6. Validators: merge the album/playlist picture upload DTOs into one + `PictureUploadRequest` and one validator (the responses are already merged). +7. `OpenApiOptionsSetup` and `ScalarOptionsSetup` share the `SecuritySchemeId` constant. +8. `.WithSummary(...)` strings in sentence case, no trailing period. (Current ones are Title Case.) +9. `JwtBearerOptionsSetup`: single-line guard without braces. +10. `Validators/Limits.cs` with every validation limit (playlist name 50, descriptions 256, + titles 200, user name 48, release year range). List them in the Log for the web-player + plan (P7.1). Database column length wins on disagreement. + +Coordination table (section A.4): P3.1 and P8.1 of the web-player plan are unblocked by P7; +P8.4 (bulk add) by P9; P7.1 (shared limits) by P12.10. Tick them in the web-player plan's Log too +(`web-player/docs/frontend-audit-plan.md`, also untracked). + +### P13: Worker and Gateway hosts + +1. Worker `Program.cs`: call `AddApplication(configuration)` instead of registering Application + options by hand (Album config is missing today), add `AddObservability`, remove + `ValidateOnBuild = false` and make the container validate. Register the extra POCO configs + the handlers need (stream tickets, gateway config) rather than disabling validation. + Then `AddUploadIntentConfiguration` (kept only for the Worker) can go. +2. Worker `appsettings.json`: complete and consistent with the Api for shared sections + (add `Album`; keys were renamed in P5), remove stale ones. +3. Gateway: keep option registration local and self-contained; field naming without + underscores (`TicketValidator` uses `_options`, `_rsa`, ...; the middleware field that + shadowed a constructor parameter is already gone); replace the default bucket in the + YARP transform of `appsettings.json` with a placeholder like `/buckets/CHANGE_ME/{**rest}` + (compose overrides it) or document it in `docs/streaming.md`. + Check `deploy/` when renaming config keys. + +### P14: Tests + +1. Add tests for what has none: MassTransit activities (generic lifecycle and picture-update + ones with the SQLite `TestDatabase`), `ProcessingSlipFaultConsumer` (every process kind), + routing slip builders (activity order and arguments), saga state machines with MassTransit's + test harness (happy path, failure, missing instance on update; sagas already use + `OnMissingInstance(Discard)`), `RecurringJobsRegistrar.EveryCron` (internal, may need + `InternalsVisibleTo`). +2. Endpoint tests for visibility and ownership if not already covered (P1, P3 added several). +3. Extract the owned-type and index configuration of `Database` into + `ModelConfiguration.Apply(ModelBuilder)` in Infrastructure and call it from `Database` and + `TestDatabase` (`Application.Tests` then references Infrastructure; note it in the Log). + +### P15: Final sweep and documentation + +1. Recompute the metrics of section A.5 (script in the plan) and fill D.1 (baseline was + 12,687 non-migration, non-test `.cs` lines; targets in A.5). Also record the MassTransit + line count (3,547 to about 2,530) and the Application line count. +2. Grep for leftovers: `Error.Unauthorized(`, `StaticPagedList`, `new ImageSize(` (only in + `PictureSizes.ToImageSizes`), `ThrowIfNull(.*nameof`, `#pragma warning disable CS8618`, + try blocks around `SaveChangesAsync` in `Application/`, `serviceDescriptors`, + `storageHandler`, `PlaylistVisibility`, log-and-rethrow in `MassTransit/Activities/Files`. +3. The analyzer rules downgraded to `suggestion` in `.editorconfig` in P0 (CA1848, CA1873, + CA1305, CA1707, CA1859, CA1716, CA1000, CA1725, CA1816, CA1822, CA1852, CA1068, CA1711, + CA1861) should be revisited: fix the cheap ones and remove their overrides. +4. Run `dotnet format Musify.slnx` and check the diff is whitespace only (some test files have a + stray blank line before the closing brace). +5. Update `docs/architecture.md`, `docs/media-processing.md`, `docs/projects.md`, + `docs/storage.md`, `docs/streaming.md` with the new names (jobs, entities, routes, config + keys such as `ParentFolder`, `Temp*`, activity/queue names, `MixKind`). +6. Add a short `backend/CLAUDE.md` with the conventions of section 3 above (this one **is** + committed). +7. Delete `ZzOpenApiDumpTests.cs`, keep the plan (section E rows should all point to a checked + part in D), and remove the `web-player/node_modules` junction only if desired (it is ignored). + +## 6. Deployment notes to keep in the Log + +Queues must be drained before deploying because these message contracts changed: +processing-slip process kinds and variables (P2), `ImageSizes` in events and arguments (P8), +new lifecycle/delete/update-picture activities and arguments (P11), `DeleteAlbumEvent` (P3). +Migrations added so far: `PlayListTrackUnique`, `DropUploadTableAndTrackAudioRetryColumns`, +`DropUserHasTrack`, `MixKind`. Old bucket objects under the `temporal/` prefix (from before the +`Temp*` config fix) may need a one-off clean-up. + +## 7. Suggested first steps for the next session + +1. `git log --oneline -14` and `git status --short` (expect only the two untracked docs and + `ZzOpenApiDumpTests.cs`). +2. Run build + tests to confirm the green baseline. +3. Start P12, commit, update the Log, then continue only if the user has not asked to stop. diff --git a/backend/docs/backend-refactor-plan.md b/backend/docs/backend-refactor-plan.md new file mode 100644 index 0000000..98b53a3 --- /dev/null +++ b/backend/docs/backend-refactor-plan.md @@ -0,0 +1,1108 @@ +# Plan: Backend hardening, cleanup and deduplication + +> Work plan for agents/AI with limited context (~128k). Based on a full read of +> **every** non-migration `.cs`, `.csproj` and `appsettings*.json` file under `backend/` +> (2026-09-24, branch `master` at `87c3f97`), plus the web-player call sites of the API. + +## How to use this document + +1. **Always read section A (Common context).** It's short and every part assumes it. +2. Pick **one part** (P0 through P15). Each part lists: objective, prerequisites, files to + read, tasks, and closing criteria. +3. One part equals one commit/PR. Don't mix parts. P0 is a pure formatting commit: never + mix formatting with behaviour changes. +4. If a part needs a shared helper from section B and it **doesn't exist yet**, create it + yourself exactly as specified in B, so any part can create it identically. +5. When you finish a part, check its box in section D (Log) and fill in the date and notes. +6. Section E maps every audit finding to the part that fixes it. Nothing in E may be left + without a part; if you discover a new finding, add it to E and to the right part. + +Recommended order (arrows are **soft** dependencies, see point 4): + +``` +P0 (format + guardrails) + ├→ P1 (security) → P2 (pipeline bugs) → P3 (query/API bugs) + └→ P4 (dead code) + ↓ +P5 (config) → P6 (domain) → P7 (application helpers) → P8 (pictures feature) + ↘ P9 (responsibilities) +P10 (infra services) → P11 (MassTransit) → P12 (API layer) → P13 (Gateway/Worker) + → P14 (tests) → P15 (final sweep) +``` + +P1 must land before anything else that touches endpoints: it closes real data leaks. + +--- + +## A. Common context (always read) + +### A.1 Solution layout + +| Project | Path | Role | +|---|---|---| +| Domain | `Core/Musify.Domain` | Entities (EF data-annotated), value objects, enums | +| Application | `Core/Musify.Application` | Mediator commands/queries (`/.cs` = record + handler), responses, contracts (`IDatabase`, `IStorageService`, …), configuration POCOs | +| Infrastructure | `Core/Musify.Infrastructure` | EF `Database`, S3 `StorageService`, ffmpeg, ImageSharp, MassTransit (activities, routing slips, sagas, consumers), jobs | +| Api | `Hosts/Musify.Api` | Minimal API endpoints, DTOs, FluentValidation validators, JWT auth | +| StreamingGateway | `Hosts/Musify.StreamingGateway` | YARP proxy to SeaweedFS with signed stream tickets | +| Worker | `Hosts/Musify.Worker` | MassTransit consumers + Hangfire + hosted jobs | +| Tests | `Tests/*` | xUnit v3. Application tests use SQLite in-memory (`TestDatabase`), Infrastructure tests use Testcontainers (need Docker) | + +Libraries: Mediator (source generator, scoped), ErrorOr, EF Core 10 + Npgsql, MassTransit 8 +(RabbitMQ, EF outbox, courier routing slips, saga state machines), Hangfire, FluentValidation, +X.PagedList (to be removed in P7), YARP. + +### A.2 Verifying each part + +From `backend/`: + +```bash +dotnet build Musify.slnx +``` + +```bash +dotnet test Musify.slnx +``` + +- The build must stay at **0 warnings** (after P0, warnings are errors). +- `Infrastructure.Tests` need Docker running. If Docker isn't available, say so in the Log; + don't skip silently. +- A part that changes an **HTTP contract** (route, request/response shape, status code) must + also: run the API locally, regenerate the web-player client (`npm run gen:api` in + `web-player/`, needs the API on `http://localhost:5111`), fix the web-player call sites, and + run `npm run check` in `web-player/`. List the contract changes in the Log. +- A part that changes the **EF model** must add a migration from `backend/`: + +```bash +dotnet ef migrations add --project Core/Musify.Infrastructure --startup-project Core/Musify.Infrastructure +``` + + Also update `Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs`, which mirrors part + of the model configuration by hand. +- A part that changes a **MassTransit message contract** (anything in `Application/Events` or + `Infrastructure/MassTransit/Arguments|Logs`) must note in the Log that queues have to be + drained before deploying (in-flight messages with the old shape will fail). + +### A.3 Known gotchas (don't reintroduce them) + +1. **Outbox.** The API uses the EF bus outbox (`UseBusOutbox`). `eventBus.PublishAsync` only + writes to the outbox; the message leaves when `SaveChangesAsync` commits. So "publish, then + save" is correct and atomic. Don't wrap the publish in its own try/catch. +2. **Worker DI.** `AddMediator()` registers **every** handler, including ones whose dependencies + the Worker doesn't have. Today that's hidden by `ValidateOnBuild = false`. P13 fixes it; until + then, don't add handler dependencies that the Worker can't resolve if the Worker sends that + command. +3. **Private IDs as strings.** `long` user ids are serialized as strings (JS precision). Keep that + when moving the converter (P9). +4. **Track pictures vs album/playlist pictures.** New tracks point at shared *preset* images until + processed; `DeleteTrackRoutingSlipBuilder` only deletes resized files when + `Pictures.IsProcessed`, so presets are never deleted. Keep that guard. +5. **Deploy overrides.** `deploy/compose.yml` overrides config via env vars (`Section__Key`). When + renaming a config key, grep `deploy/` too. +6. **The web-player is the only client.** Endpoints it doesn't call (see E.2) can change freely; + the others need the regeneration step in A.2. + +### A.4 Coordination with the web-player plan + +`web-player/docs/frontend-audit-plan.md` has parts marked 🔗 that need this backend. They are +scheduled here so both plans converge: + +| Web-player part | Needs from the backend | Done in | +|---|---|---| +| P3.1 playlist permissions | `ownerUserId` in `PlayListApplicationResponse` | P7.9 | +| P8.1 performance | `trackCount` and `durationSeconds` in `PlayListApplicationResponse` | P7.9 | +| P8.4 add album to playlist | bulk insert: add many tracks to a playlist in one request | P9.4 | +| P7.1 shared `LIMITS` | the real validator limits (playlist description is 256 here, track title 200) | P12.10 | + +When one of these lands, tick it in **both** Logs. + +### A.5 Measured baseline (2026-09-24) + +| Metric | Value | Target | +|---|---|---| +| Non-migration `.cs` lines (excluding tests) | 12,687 | −25–30 % | +| Build warnings | 0 (no analyzers, no style rules) | 0 with analyzers + style enforced | +| Files with block namespace / file-scoped | 249 / 50 | 0 / all | +| Files with UTF-8 BOM | 31 (2 with a BOM mid-file) | 0 | +| Files without final newline | 61 | 0 | +| Stray spaces (`} );`, `nameof(X) )]`, `{Id} )`) | 43 | 0 | +| `ThrowIfNull(x, nameof(x))` | 19 | 0 | +| `new ImageSize(` | 15 | 3 (inside `PictureSizes`) | +| Cover-ids projection copies | 9 | 1 per entity | +| `ListeningHistories.Count(l => l.IsCounted)` copies | 9 | 1 | +| `new StaticPagedList` | 7 | 0 | +| `Error.Unauthorized()` used for "not owner" | 10 | 0 | + +Recompute with the script in P15 and record in D.1. + +--- + +## B. Shared specs (single source of truth) + +### B.1 Style guardrails + +#### B.1.1 `backend/.editorconfig` (create in P0) + +```ini +root = true + +[*] +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space + +[*.{csproj,props,targets,slnx,xml}] +indent_size = 2 + +[*.json] +indent_size = 4 + +[**/Migrations/**.cs] +generated_code = true + +[*.cs] +indent_size = 4 + +csharp_style_namespace_declarations = file_scoped:warning +csharp_using_directive_placement = outside_namespace:warning +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false +dotnet_diagnostic.IDE0005.severity = warning + +dotnet_style_require_accessibility_modifiers = always:warning +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion +csharp_prefer_braces = when_multiline:warning +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion + +dotnet_naming_rule.private_fields.symbols = private_fields +dotnet_naming_rule.private_fields.style = camel_case +dotnet_naming_rule.private_fields.severity = warning +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private +dotnet_naming_symbols.private_fields.required_modifiers = +dotnet_naming_style.camel_case.capitalization = camel_case + +dotnet_naming_rule.private_constants.symbols = private_constants +dotnet_naming_rule.private_constants.style = pascal_case +dotnet_naming_rule.private_constants.severity = warning +dotnet_naming_symbols.private_constants.applicable_kinds = field +dotnet_naming_symbols.private_constants.required_modifiers = const +dotnet_naming_style.pascal_case.capitalization = pascal_case +``` + +Private fields are **camelCase without underscore** (matches primary-constructor parameters, +which are the majority of the code). Constants and `static readonly` are PascalCase. + +#### B.1.2 `backend/Directory.Build.props` (update in P0) + +```xml + + + + net10.0 + enable + enable + latest-recommended + true + true + true + $(NoWarn);CS1591 + + + +``` + +`GenerateDocumentationFile` is only there so IDE0005 (unused usings) is reported on build. +If `latest-recommended` raises rules that would force behaviour changes in P0, set those +specific rules to `suggestion` in `.editorconfig` with a comment naming the part that will fix +them, and remove the override in that part. + +### B.2 Coding conventions (apply in every part you touch) + +1. **File layout.** `Application//.cs` holds the command/query record and its + handler. Responses live in `Application//Responses/`. No response records declared + inside handler files, no mapping methods on commands. +2. **Errors** (via `AppErrors`, B.3.6): + - missing, or exists but not visible to the caller → `NotFound` + - exists but caller isn't the owner → `Forbidden` (403). **Never** `Unauthorized` for that; + `Unauthorized` (401) is only for "not signed in". + - duplicate/state clash → `Conflict`; bad input → `Validation`. + - Every error has a code (`"Album.NotFound"`) and a description. +3. **Success value:** `Result.Success`. +4. **No try/catch around `SaveChangesAsync` or `PublishAsync` in handlers.** Unexpected + exceptions go to the global `UseExceptionHandler`. Catch only what you can turn into a + domain error (e.g. unique-constraint violation → `Conflict`, see B.3.7). +5. **Logging.** Handlers log state changes at `Information` (created/updated/deleted). Not-found + and forbidden aren't logged (the HTTP log already shows them). Infrastructure code **either** + handles an exception **or** rethrows it; never log-and-rethrow. +6. **Queries never write.** If a read needs a side effect, it's a command. +7. **Return types.** A handler that can't fail returns the plain type (`ValueTask`), not + `ErrorOr`. A handler that can fail returns `ErrorOr`. Never return a lazy `IEnumerable`. +8. **Lists** are paginated with `PageRequest` (B.3.4) and have a deterministic order with `Id` as + the final tiebreaker. Public reads only return `LifeCycleStatus.Active` rows. +9. **Text input:** stored names/titles are trimmed; normalized columns use + `TextNormalizer.Normalize` (B.3.5). +10. **Entities:** no `[Required]` on value types, no `[Required]` next to `required` on + non-nullable strings, no initializers equal to the default (`= 0`, `Id = Guid.NewGuid()` in + object initializers). Navigations: `public User Owner { get; set; } = null!;` (no + `#pragma warning disable CS8618`, no `required` on navigations). +11. **Naming:** `PlayList` everywhere (types, variables, route parameters: `{playListId}` → + see P12 for the route style). `…Configuration` for config POCOs. Injected services are named + after their type (`storageService`, not `storageHandler`). +12. **`ArgumentNullException.ThrowIfNull(x)`** without `nameof`. +13. User-facing strings (mix titles, error descriptions) are English; the web-player owns + translation. + +### B.3 Shared helpers (exact paths and APIs) + +#### B.3.1 Pictures configuration (`Application/Configuration/Pictures.cs`) + +Replaces `AlbumRoutes`, `PlayListRoutes`, the picture half of `TrackRoutes`, and the three +`*PicturesSizes` classes. + +```csharp +namespace Musify.Application.Configuration; + +public enum PictureSize { Small, Medium, Large } + +public class PictureRoutes +{ + [Required] public string UploadsFolder { get; set; } = "uploads"; + [Required] public string ParentFolder { get; set; } = string.Empty; + [Required] public string OriginalPicturesFolder { get; set; } = "OriginalPictures"; + [Required] public string SmallPicturesFolder { get; set; } = "SmallPictures"; + [Required] public string MediumPicturesFolder { get; set; } = "MediumPictures"; + [Required] public string LargePicturesFolder { get; set; } = "LargePictures"; + + public string FolderPath(PictureSize size); // ParentFolder/ + public string BuildPicturePath(PictureSize size, string name); // FolderPath(size)/name + public string BuildOriginalPicturePath(long userId, string name); // uploads/{userId}/ParentFolder/OriginalPictures/name + public string BuildTempPath(string tempRootPrefix, long userId, string objectName); +} + +public class PictureSizes +{ + [Range(1, 1024)] public int SmallWidth { get; set; } = 128; + [Range(1, 1024)] public int SmallHeight { get; set; } = 128; + [Range(1, 1024)] public int MediumWidth { get; set; } = 256; + [Range(1, 1024)] public int MediumHeight { get; set; } = 256; + [Range(1, 1024)] public int LargeWidth { get; set; } = 512; + [Range(1, 1024)] public int LargeHeight { get; set; } = 512; + + public ImageSizes ToImageSizes(PictureRoutes routes); +} + +public interface IPictureOwnerConfiguration +{ + PictureRoutes Routes { get; } + PictureSizes PicturesSizes { get; } +} +``` + +- `AlbumConfiguration` and `PlayListConfiguration` become `{ PictureRoutes Routes; PictureSizes PicturesSizes; }` + implementing `IPictureOwnerConfiguration`. +- `TrackConfiguration.Routes` becomes `TrackRoutes : PictureRoutes` adding only the preset and + audio members (`PresetSmallPicture…`, `OriginalAudiosFolder`, `ProcessedAudiosFolder`, + `BuildOriginalAudioPath(userId, name)`, `BuildProcessedAudioPath(folder)`). + `BuildTempAudioPath` is deleted (it was identical to the picture one): use `BuildTempPath`. +- Config keys change: `ParentFolders` → `ParentFolder` (Album/PlayList), `ProcessedAudioFolder` → + `ProcessedAudiosFolder`, `SmallPictureWidth` → `SmallWidth` etc. Update every `appsettings*.json` + and grep `deploy/`. +- The un-prefixed `BuildOriginalPicturePath(string name)` overloads and the `…PicturesPath` + properties that nobody needs after P8 are removed. + +#### B.3.2 `ImageSizes` (`Application/Shared/ImageSize.cs`) + +```csharp +public record ImageSize(string SavePath, int Width, int Height); + +public record ImageSizes(ImageSize Small, ImageSize Medium, ImageSize Large); +``` + +Events and routing-slip arguments carry one `ImageSizes Sizes` instead of three `ImageSize` +parameters (message contract change, see A.2). + +#### B.3.3 `EntityPictures` (`Domain/ValueObjects/EntityPictures.cs`) + +Replaces `AlbumPictures` and `PlayListPictures` (identical today). Same four string properties. +Column names are mapped explicitly in `Database.cs`, so **no migration** is needed; verify with +`dotnet ef migrations add` producing an empty migration, then delete it. + +Also add a factory used by create/update handlers: + +```csharp +public static EntityPictures Pending(string objectName); +``` + +See P3 for why "pending" must not point the resized names at the original file. + +#### B.3.4 Pagination (`Application/Shared/Pagination.cs`) + +Replaces X.PagedList (remove `X.PagedList` and `X.PagedList.EF` packages at the end of P7). + +```csharp +public record PageRequest(int PageNumber = 1, int PageSize = 20) +{ + public const int MaxPageSize = 100; +} + +public record PaginatedResponse( + IReadOnlyList Items, + int PageNumber, + int PageSize, + int PageCount, + int TotalItemCount, + bool HasNextPage, + bool HasPreviousPage); + +public static class PaginationExtensions +{ + public static Task> ToPaginatedAsync( + this IQueryable query, PageRequest page, CancellationToken cancellationToken); +} +``` + +- `ToPaginatedAsync` clamps `PageNumber >= 1` and `1 <= PageSize <= MaxPageSize`, does + `CountAsync` + `Skip/Take` on the **same** filtered query. +- The query passed in must already be ordered and projected (B.3.5). +- The JSON shape of `PaginatedResponse` stays exactly as today (same property names and order). +- API side: endpoints bind `[AsParameters] PageRequest page`; `PageRequestValidator` rejects + `PageNumber < 1` and `PageSize` outside `1..100` with a 400. + +#### B.3.5 Projections and text (`Application//Responses/*Projections.cs`, `Application/Shared/TextNormalizer.cs`) + +```csharp +public static class TrackProjections +{ + public static IQueryable Active(this IQueryable query); + public static IQueryable SelectResponse(this IQueryable query); +} + +public static class AlbumProjections +{ + public const int CoverTrackCount = 4; + public static IQueryable Active(this IQueryable query); + public static IQueryable SelectResponse(this IQueryable query); +} + +public static class PlayListProjections +{ + public const int CoverTrackCount = 4; + public static IQueryable Active(this IQueryable query); + public static IQueryable VisibleTo(this IQueryable query, long? viewerId); + public static IQueryable SelectResponse(this IQueryable query); +} + +public static class TextNormalizer +{ + public static string Normalize(string value); // value.Trim().ToUpperInvariant() +} +``` + +- `SelectResponse` is a single EF-translatable `Select(...)` building the response directly + (owner name, tag list, `ListeningHistories.Count(l => l.IsCounted)`, track count, cover ids). + No `Include`, no anonymous intermediate types, no `FromEntity` in queries. +- Keep `FromEntity` only where a handler already has a tracked entity in memory (create/update). +- `VisibleTo(viewerId)`: `Visibility == Public || OwnerUserId == viewerId`. +- Join-table queries (album tracks, playlist tracks, likes) order the join rows, then + `Select(x => x.Track)` and call `SelectResponse()`. + +#### B.3.6 Errors and ownership (`Application/Shared/AppErrors.cs`, `Application/Shared/OwnershipExtensions.cs`) + +```csharp +public static class AppErrors +{ + public static Error NotFound(string entity, object id); // code ".NotFound" + public static Error Forbidden(string entity, object id); // code ".Forbidden" + public static Error Conflict(string code, string description); +} + +public interface IOwnedEntity +{ + Guid Id { get; } + long OwnerUserId { get; } +} + +public static class OwnershipExtensions +{ + public static Task> FindOwnedAsync( + this DbSet set, Guid id, long userId, CancellationToken cancellationToken) + where T : class, IOwnedEntity; +} +``` + +`Track`, `Album` and `PlayList` implement `IOwnedEntity`. `PlayList.UserId` is renamed to +`OwnerUserId` with `[Column("UserId")]` (no migration). + +#### B.3.7 Unique-violation mapping (`Application/Contracts/IDatabase.cs`) + +```csharp +Task> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken); +``` + +Implemented in `Database` by catching `DbUpdateException` whose inner exception is +`PostgresException { SqlState: "23505" }` (and the SQLite equivalent in `TestDatabase`). Used by +like, follow, add-to-playlist/album. + +#### B.3.8 Lifecycle (`Domain/Abstractions/IHasLifeCycle.cs`) + +```csharp +public interface IHasLifeCycle +{ + Guid Id { get; } + LifeCycleStatus LifeCycleStatus { get; set; } +} +``` + +Implemented by `Track`, `Album`, `PlayList`. Used by the generic MassTransit activities (P11). + +#### B.3.9 Routing slips (`Infrastructure/MassTransit/RoutingSlip/RoutingSlips.cs`) + +Replaces the preamble copied in 10 builders/consumers and `Consumers/EndpointHelper.cs`. + +```csharp +internal static class RoutingSlips +{ + public static RoutingSlipBuilder Create(Guid? correlationId); // new id + CorrelationId variable + clean-up subscription + public static RoutingSlipBuilder AddStep(this RoutingSlipBuilder builder, string name, string executeEndpointName, object arguments); + public static RoutingSlipBuilder TrackFaults(this RoutingSlipBuilder builder, Guid subjectId, string processKind); // SubjectId + ProcessKind + fault subscription +} +``` + +`RoutingSlipVariableNames` moves to namespace `Musify.Infrastructure.MassTransit.RoutingSlip` +(it lives in that folder already). + +--- + +## C. Parts + +### P0: Formatting and guardrails (pure formatting commit) + +**Objective:** make style mechanical so later parts don't argue about it. + +**Tasks** +1. Create `backend/.editorconfig` (B.1.1) and update `Directory.Build.props` (B.1.2). +2. Remove BOMs from all `.cs` files (including the two mid-file ones in + `Application/Events/UpdatePlayListPictureEvent.cs` and `UpdateTrackPictureEvent.cs`). +3. From `backend/`: + +```bash +dotnet format Musify.slnx +``` + + then fix by hand whatever `dotnet format` can't: the 43 stray spaces (`} );`, `} ));`, + `nameof(X) )]`, `(PID: {ProcessId} )`, `prefix: {Prefix} )`, `if (a == null|| …)`), blank + lines right after `{` of a namespace/class, and missing `private` modifiers. +4. Build with warnings as errors. For analyzer rules that need behaviour changes, downgrade them + in `.editorconfig` with a `# fixed in Pn` comment (see B.1.2). + +**Don't:** rename anything, change any logic, touch migrations. + +**Closing criteria:** `git diff --stat` shows only whitespace/namespace/using changes (review +with `git diff -w`); 0 warnings; tests green; metrics in A.5 for namespaces, BOM, final newline, +stray spaces are at target. + +--- + +### P1: Security and privacy fixes + +**Objective:** close data leaks and abuse paths. Small, surgical, test each one. + +**Files to read:** `Hosts/Musify.Api/Endpoints/UserEndpoints.cs`, `PlayListEndpoints.cs`, +`Application/PlayLists/*.cs`, `Application/Services/UploadIntentValidator.cs`, +`Application/Tracks/CreateTrack.cs`, `Application/Albums/CreateAlbum.cs`, +`Application/Albums/UpdateAlbum.cs`, `Application/PlayLists/CreatePlayList.cs`, +`Application/PlayLists/UpdatePlayList.cs`, the upload validators in `Hosts/Musify.Api/Validators`, +`Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs`. + +**Tasks** +1. **Delete `POST /users`** (anonymous, lets anyone create a user with any id). Remove + `CreateUserCommand`, `CreateUserRequest`, `CreateUserRequestValidator` and their tests. Users + are provisioned only by `SyncUserCommand` on token validation. The web-player doesn't call it. +2. **Private playlists:** + - `GET /playlists` (all users' playlists, not used by the web-player): delete it and + `GetPlayListsQuery`. + - `GET /playlists/users/{userId}`: remove the `onlyPublic` parameter; the handler gets + `ViewerId` and applies `VisibleTo(viewerId)` (owner sees everything, others only public). + Contract change: web-player `user/[id]/+page.server.ts` stops sending `onlyPublic`. + - `GET /playlists/{id}/tracks`: take `CurrentUser`, return `NotFound` when the playlist isn't + visible to the viewer (same rule as `GetPlayListById`). + - `GET /playlists/{id}/cover`: **decision (recommended default): leave public.** The web-player + proxies covers without the user's token (`web-player/src/lib/server/image.ts`) and ids are + unguessable GUIDs. Write that decision in the Log. If you choose to protect it, the proxy + must forward the session token and the cache header must become `private`. +3. **Upload intents:** `ValidateAndLoadAsync` takes the expected `UploadIntentPurpose` and returns + `Validation` if it doesn't match. `CreateTrack` rejects `PictureIntentId == AudioIntentId`. +4. **Upload request validation** (both picture validators and the track one): + - `FileType`: letters/digits only, 1–10 characters, from an allow-list (`jpg, jpeg, png, webp` for + pictures; `mp3, wav, flac, ogg, m4a, aac` for audio). Put the lists in one static class in + `Hosts/Musify.Api/Validators/Uploads.cs` so the three validators share them. + - `ContentType`: must match the allow-list (`image/*` subset, `audio/*` subset). + - `ExpectedSizeBytes`: `> 0` and `<= UploadIntentConfiguration.MaxUploadBytes`. +5. **Gateway range clamp:** when a ticket has `maxBytes`, an unparsable `Range` header or a + multi-range request must be **replaced** by `bytes=0-{maxBytes-1}` (not passed through). + Handle suffix ranges (`bytes=-N`) by rejecting them with 416 when `maxBytes` is set. +6. **Listening data privacy — decision (recommended default): require auth and self only** for + `GET /users/{id}/listening-stats` (the web-player only reads it for the current user; check + before changing). Leave `listening-history` and `last-listened-track` public (the profile page + shows them) and write that in the Log. + +**Tests to add:** anonymous `POST /users` → 404/405; other user's private playlist via +`/playlists/users/{id}` and `/playlists/{id}/tracks` → hidden; intent purpose mismatch; +same intent twice in `CreateTrack`; gateway middleware with multi-range and invalid range when +`maxBytes` is set. + +**Closing criteria:** tests above green; web-player regenerated and `npm run check` clean. + +--- + +### P2: Processing pipeline bugs (Worker side) + +**Files to read:** `Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs`, +`RoutingSlip/Builders/*.cs`, `Sagas/*.cs`, `Activities/Audio/*.cs`, both `appsettings.json` +(Api, Worker), `Application/Configuration/UploadIntentConfiguration.cs`. + +**Tasks** +1. `ProcessingSlipFaultConsumer`: add the `AlbumPicture` case publishing + `AlbumPictureProcessingFailed`. Today album sagas stay in `Processing` forever on failure. +2. Config keys: the appsettings use `TempRootPrefix`, `TempUploadsRetentionDays`, + `TempCleanupJobIntervalSeconds`; the class expects `TemporalRootPrefix`, + `TemporalUploadsRetentionDays`, `TemporalCleanUpJobIntervalSeconds`, so they're silently + ignored. Rename the **class** properties to `TempRootPrefix`, `TempUploadsRetentionDays`, + `TempCleanupJobIntervalSeconds` (shorter, and what ops already configured), keep the value + `"temp"`. Check that no object with the `temporal/` prefix is left in the bucket (the clean-up + job only lists the configured prefix); if there are, note it in the Log for a one-off clean-up. +3. The create-track, create-album and create-playlist slips (copy to final + consume intents + + publish processing event) have no fault subscription: if they fault the entity stays + `Pending` forever. Add `TrackFaults(...)` with new process kinds `TrackCreation`, + `AlbumCreation`, `PlayListCreation` that publish the entity's `*ProcessingFailed` event + directly (so the existing failed-consumers mark it as failed and remove files). +4. Duration precision: `TranscodeAudioActivity` stores `(int)Math.Round(...)`; store the `double` + `TotalSeconds`. +5. `UpdateTrackAudioActivity`: when the folder key is empty it publishes `TrackAudioProcessed` + without marking the audio as completed. Throw instead (it's a programming error) so the slip + faults and the saga marks the track failed. +6. Sagas and picture **updates**: `*PictureProcessed` / `*PictureProcessingFailed` for an update + arrive at a saga that was finalized after creation. Configure + `OnMissingInstance(m => m.Discard())` on those events in the three state machines, and verify + against a local RabbitMQ that updating a picture no longer puts messages in `*_error` queues. + Record the before/after in the Log (this was "to verify" in the audit). + +**Closing criteria:** manual run of the stack (`deploy/compose.dev.yml`): create album with a +corrupt image → album ends `Failed`; update a playlist picture → no `_error` messages. + +--- + +### P3: Query and API bugs + +**Files to read:** `Hosts/Musify.Api/Extensions/ErrorOrHttpExtensions.cs`, all +`Application/**/Get*.cs`, `Application/PlayLists/UpdatePlayList.cs`, `CreatePlayList.cs`, +`Application/Albums/CreateAlbum.cs`, `UpdateAlbum.cs`, `DeleteAlbum.cs`, `Application/Likes/*.cs`, +`Application/Users/FollowUser.cs`, `Application/*/AddTrackTo*.cs`, +`Infrastructure/Persistence/Database.cs`. + +**Tasks** +1. **403 for "not owner":** replace the 10 `Error.Unauthorized()` with `AppErrors.Forbidden` + (B.3.6). In `ErrorOrHttpExtensions`, map `Forbidden` to `Results.Problem(statusCode: 403)`, + and make `NotFound`/`Conflict` return ProblemDetails too (today they return a bare string). + Update `.Produces(401)` to `.Produces(403)` on owner-only endpoints. Contract change. +2. **Pagination bounds:** introduce `PageRequest` + validator (B.3.4) on every paginated endpoint + (today `pageSize=0` → 500, `pageSize=1000000` → unbounded). Bound `limit` on + `/albums/recent` to `1..50`. Remove the ad-hoc clamp in `GetTracksByUserId`. +3. `GetPlayListsByUserId`: `totalCount` is computed before the `Active` filter; move the filter + before counting (P7 will replace this with `ToPaginatedAsync` anyway). +4. **Lifecycle filter:** public reads (`GetTracks`, `GetTracksByUserId`, `GetTrackById`, + `GetAlbums*`, `GetAlbumById`, `GetAlbumTracks`, `GetPlayListTracks`, `GetLikedTracks`, + `GetGenres`, `MixItemMapper`, `GetListeningHistory`, `GetRecentlyListenedAlbums`) only return + `Active` rows. Stream only `Active` tracks. +5. **Covers before processing:** `CreateAlbum`/`UpdateAlbum`/`CreatePlayList`/`UpdatePlayList` + set `Small/Medium/LargeName = objectName`, so `/cover?size=small` looks for + `…/SmallPictures/{objectName}`, which doesn't exist until processing ends → S3 throws → 500. + Fix: resized names stay `null` until the worker sets them (`EntityPictures.Pending` in B.3.3 + sets only `OriginalName`), and the cover query falls back to the **original** key while the + resized one is missing. Also make the cover endpoints return 404 (not 500) when the object + doesn't exist (`HeadObjectAsync` or catch `NoSuchKey` in the storage service → `null`). +6. **Races → 500:** use `TrySaveChangesAsync` (B.3.7) in `ToggleTrackLike`, `FollowUser`, + `AddTrackToPlayList`, `AddTrackToAlbum`, mapping the unique violation to a no-op success + (like/follow) or `Conflict` (add track). Add a unique index on + `PlayListHasTrack (PlayListId, TrackId)` (migration; dedupe existing rows in the migration + with `DELETE … USING` keeping the lowest position). +7. **Track/position numbering:** compute next position with + `MaxAsync(x => (int?)x.Position) ?? -1` in one query. Decide one base for both playlist + positions and album track numbers (recommended: playlists 0-based internal position, album + track numbers 1-based because they're user-visible; document it in the Log) and renumber on + removal in **both** (playlists currently leave gaps). +8. `UpdatePlayList`: trim the name; allow clearing the description (`""` → `null`; `null` means + "unchanged"); validate `NewVisibility` with `IsInEnum()`. `CreatePlayList`: trim the name. + Same `IsInEnum()` on `CreatePlayListRequest.Visibility`. +9. `GetMixesByUserId` N+1: one query for all cover items of all mixes. +10. `GetLastTrackListenedByUserId` counts uncounted listens while the history only shows counted + ones: filter `IsCounted` for consistency, and return 204 (not 404) when there's none (check + the web-player call site in `(app)/+page.server.ts` or wherever it's read). +11. `GetListeningStats` streak: count the streak ending today **or yesterday** (a streak isn't + broken until the day is over). +12. `DeleteAlbum` removes the row directly and leaves its pictures in storage. Give it the same + shape as playlists: mark `Removing`, publish `DeleteAlbumEvent`, and a + `DeleteAlbumRoutingSlipBuilder` that removes the four pictures and the row. (If P11 already + landed, build it with the generic activities.) + +**Closing criteria:** each task has a test (Application tests for handlers, Api tests for status +codes); web-player regenerated; `npm run check` clean. + +--- + +### P4: Dead code removal + +**Objective:** delete what nothing uses, before refactoring it. + +**Tasks** +1. `Infrastructure/Services/SingleFlightCache.cs` + its test + `Microsoft.Extensions.Caching.Memory` + from `Infrastructure.csproj` (the API gets `IMemoryCache` from `AddMemoryCache`). +2. `IStorageService.GetUrlAsync` and `RemoveFolderAsync` (only tests use the latter) + tests. +3. `IAudioTranscoderService.IsValidAudioFileAsync` + implementation + `BuildValidateAudioArguments`. +4. `DownloadFileFromUrlActivity`, `DownloadFileFromUrlArguments`, `DownloadFileFromUrlLog`, its + registration, `ActivityNames.DownloadThumbnail`, and `services.AddHttpClient()` in the Worker. +5. `TrackAudio.DownloadRequested`, `RetryCount`, `LastRetryAt` and their mapping (migration + `DropTrackAudioRetryColumns`). +6. `Upload` entity, `UploadState` enum, `IDatabase.Uploads`, the `Upload` writes in + `StorageService` (migration `DropUploadTable`). Update `docs/architecture.md` (mentions + `Upload`). +7. `DatabaseDesignTimeFactory`: remove the unused `optionsBuilder`. +8. `Database.cs`: remove the `AppIDatabase` alias. +9. Unused locals (`var response =` in `StorageService`), `.AsNoTracking().AsQueryable()`, + redundant `database.X.Update(entity)` on tracked entities, the always-true + `ExpiredIntentsRetentionDays >= 0`, the duplicated `if (pictureIntent != null && …)` in + `UpdateAlbum`/`UpdatePlayList`, `AddPictureService`'s unused `configuration` parameter, + `AddUploadIntentConfiguration` (already done by `AddApplication`; see P13 for the Worker). +10. `Newtonsoft.Json` from `Api.csproj` and `Worker.csproj`; package references in `Api.csproj` + and `Worker.csproj` that come transitively from Infrastructure (AWSSDK.S3, MassTransit.*, + Npgsql, EF, OpenTelemetry.*, MimeMapping, ImageSharp, X.PagedList.EF). Keep only what the host + uses directly. Rename the Worker `UserSecretsId` prefix (`dotnet-PictureWorker-…`) only if + nobody relies on it locally (ask; otherwise leave and note). +11. `appsettings.json` of the API: remove `AudioTranscoder` and `Workers` (Worker-only). + Worker: remove `AudioTranscoder.Routes.WorkingDirectory` (doesn't exist). +12. `CurrentUser.HasClaim` / `FindClaim` / `Principal` if unused after grep. + +**Closing criteria:** grep proves each symbol is gone; migrations added; tests green. + +--- + +### P5: Configuration unification + +**Prerequisites:** P2 (config key rename there). + +**Tasks** +1. Create `Pictures.cs` (B.3.1) and `ImageSizes` (B.3.2); rewrite `AlbumConfiguration`, + `PlayListConfiguration`, `TrackConfiguration` on top of them. Update every call site + (`BuildSmallPicturePath(x)` → `BuildPicturePath(PictureSize.Small, x)`). +2. Update all `appsettings*.json` with the renamed keys and grep `deploy/`. +3. **One way to register options:** `AddValidatedOptions` (move it to + `Application/Configuration/ConfigurationDependencyInjection.cs` if not there, public). Replace + `RegisterValidatedOptions` in MassTransit DI and the inline copies in `AddDatabase`, + `AddStorageService`, `AddObservability`, `AddAudioTranscoder`, `AddStreamTicketService`, + `AddAuthenticationConfiguration`. Add `public interface IConfigurationOptions { static abstract string SectionName { get; } }`, + implement it on every config class, and add the overload + `AddValidatedOptions(this IServiceCollection services, IConfiguration configuration) where T : class, IConfigurationOptions` + so call sites stop repeating the section name. +4. Parameter name `services` everywhere (not `serviceDescriptors`). +5. Gateway: rename `StreamTicketValidationOptions` → `StreamTicketValidationConfiguration` and + inject the POCO like the rest (no `IOptions` in constructors). + +**Closing criteria:** `grep -r "AddOptionsWithValidateOnStart" backend` finds only +`AddValidatedOptions`; `new ImageSize(` appears only in `PictureSizes.ToImageSizes`. + +--- + +### P6: Domain cleanup + +**Tasks** +1. `EntityPictures` (B.3.3) replaces `AlbumPictures` and `PlayListPictures`. +2. `IHasLifeCycle` (B.3.8) and `IOwnedEntity` (B.3.6); rename `PlayList.UserId` → + `OwnerUserId` with `[Column("UserId")]`. +3. Apply B.2 rule 10 to every entity: drop redundant `[Required]`, redundant initializers, + `#pragma warning disable CS8618` (use `= null!` on navigations), `required` on `Track.Owner` + (then `CreateTrack` no longer needs to load the `User` tracked; it sets `OwnerUserId` only). +4. Rename `PlaylistVisibility` → `PlayListVisibility` (enum type name only; the DB stores ints, + JSON stores names, so no migration and no contract change — verify the OpenAPI schema name + change doesn't break web-player types; regenerate). +5. Table names: **decision (recommended default): don't rename tables** (pure churn + risky + migration). Instead make every entity declare `[Table("…")]` explicitly with its current name + so the inconsistency is visible and frozen. Note in the Log. +6. `UploadIntent`: move `IsExpired`'s clock read out of the entity (`IsExpiredAt(DateTime now)`) + so it's testable. + +**Closing criteria:** `dotnet ef migrations add Check` produces an empty migration (delete it); +tests green. + +--- + +### P7: Application shared helpers + +**Prerequisites:** P6. + +**Tasks** +1. Create `Pagination.cs` (B.3.4), `*Projections.cs` + `TextNormalizer` (B.3.5), `AppErrors` + + `OwnershipExtensions` (B.3.6), `TrySaveChangesAsync` (B.3.7) if P3 didn't. +2. Rewrite every list query with `…Active().VisibleTo(…).OrderBy(…).SelectResponse().ToPaginatedAsync(page, ct)`. + Delete every `StaticPagedList`, `ToPagedListAsync`, `Include` used only for mapping, and the + `X.PagedList*` package references. +3. Delete `TracksSearchResponse`, `TrackSearchItemResponse`, `AlbumsSearchResponse`, + `AlbumSearchItemResponse`; `GetTracks` and `GetAlbums` return `PaginatedResponse<…>`. + **Contract change:** web-player `explore/+page.svelte` uses `items.map((item) => item.track)` + and the album equivalent; update to use items directly. +4. Replace the load/exists/owner/log boilerplate in every command with `FindOwnedAsync`. +5. Apply B.2 rules 2–5 and 7 in every handler: `AppErrors`, `Result.Success`, no try/catch around + save/publish, logging policy, plain return types for handlers that can't fail (`GetUsers`, + `GetLikedTracks`, `GetRecentlyListenedAlbums`, `GetMixesByUserId`…), `GetListeningHistory` + returns `IReadOnlyList`. +6. Move inline responses to `Responses/`: `UserProfileResponse`, `ListeningStatsResponse`, + `GenreResponse`, `AvailableGenreResponse`, the three `*CoverLocation` (which become one + `CoverLocation` in P8). +7. Use `TextNormalizer.Normalize` for every normalized column and search term. +8. Deterministic ordering with `Id` tiebreaker everywhere; `GetTracksByUserId` orders by + `CreatedAt` descending like the rest. +9. `PlayListProjections.SelectResponse` adds `OwnerUserId` (string-serialized, A.3.3), + `TrackCount` and `DurationSeconds` (sum of active tracks) to `PlayListApplicationResponse` + (additive contract change, needed by web-player P3.1 and P8.1; see A.4). + +**Closing criteria:** A.5 metrics for cover-ids projection, listens-count projection and +`StaticPagedList` at target; line count of `Application` down ≥ 20 %. + +--- + +### P8: Pictures feature deduplication + +**Prerequisites:** P5, P6, P7. + +**Objective:** Album and PlayList picture flows are copies of each other (and of the picture half +of Track). Make them one implementation. + +**Tasks** +1. `Application/Pictures/RequestPictureUpload.cs`: one internal service + `PictureUploadIssuer.IssueAsync(userId, purpose, IPictureOwnerConfiguration, fileType, contentType, expectedSize, ct)` + returning `ErrorOr`; `RequestAlbumPictureUpload` and + `RequestPlayListPictureUpload` become thin commands calling it. `AlbumPictureUploadResponse` + and `PlayListPictureUploadResponse` merge into `PictureUploadResponse` (same shape; OpenAPI name + changes → regenerate web-player types). `RequestTrackUploadUrls` reuses the intent-creation + part for both of its intents. + Also: issue the pre-signed URL **after** the quota check (today it's issued first and wasted + on rejection). +2. `Application/Pictures/GetCover.cs`: one `GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size)` + (`enum CoverOwner { Track, Album, PlayList }`), one `CoverLocation`, content type from + `MimeUtility` for all three (Track currently hand-rolls it). Size is parsed in the API with + `Enum.TryParse` (default `Medium`). +3. Create/Update album and playlist: extract `PictureSourceChange.PrepareAsync(intentId, userId, IPictureOwnerConfiguration, purpose)` + returning the `EntityPictures` and the event payload; handlers shrink to entity changes + + publish. +4. Events: `CreateAlbumResourcesEvent`/`CreatePlayListResourcesEvent` and + `UpdateAlbumPictureSourceEvent`/`UpdatePlayListPictureSourceEvent` keep separate types (the + sagas correlate on them) but carry `ImageSizes Sizes` (B.3.2). Message contract change (A.2). +5. `UploadIntentValidator`: stop receiving `UploadIntentConfiguration` as a method parameter; + inject it. Quota: compute count and bytes in one aggregate query instead of loading rows, use + the audio default for audio intents, and ignore intents whose `ExpiresAt` already passed. + +**Closing criteria:** `diff` between album and playlist handlers is only names; the picture +handlers in `Albums/` and `PlayLists/` are each ≤ 40 lines. + +--- + +### P9: Responsibilities + +**Tasks** +1. **Queries don't write:** split `GetTrackStreamQuery` into `StartListeningCommand` (creates the + `ListeningHistory`, returns stream ticket + `listenId`). `TrackStreamIssuer` only issues the + ticket and builds the URL. Same HTTP route and response (no contract change). +2. **Serialization belongs to the API:** move `LongAsStringConverter`/`NullableLongAsStringConverter` + to `Hosts/Musify.Api/Serialization/`, remove `[JsonConverter]` attributes from Application + responses, and register both converters globally in `ConfigureHttpJsonOptions`. That is safe + because the only `long` values the API returns are user ids (`ExpectedSizeBytes` is + request-only and the converter also reads numbers; `TotalItemCount` is `int`); re-check with + a grep before merging. Update the OpenAPI schema transformer to mark every `long` as string, + and check that the regenerated web-player types don't change (they already see strings). +3. **One validation place:** tag rules only in `CreateTrackRequestValidator` (remove from the + handler, keep a domain guard `GenreCompatibility.FindConflicts` call in the handler only if + commands can be sent from somewhere other than the API — they can't today, so remove); + `RecordListeningProgress` gets a FluentValidation validator instead of handler checks; genre + parsing in `GetTracks` moves to binding (`Genre? genre` with a validator). +4. **Endpoint orchestration + bulk insert:** the `AddPlayListTrack` endpoint calls one command + and stops re-querying the track. Replace it with `POST /playlists/{id}/tracks` taking + `{ "trackIds": [...] }` (1–500 ids) → 204, backed by `AddTracksToPlayListCommand` that appends + the tracks in order in one transaction, skipping those already present. This is the bulk + endpoint web-player P8.4 asks for (A.4) and removes the per-track POST loop in + `web-player/src/lib/server/playlistActions.ts`. For consistency, albums get the same shape + (`POST /albums/{id}/tracks` with `trackIds`), replacing `POST /albums/{albumId}/tracks/{trackId}`. + Contract change on both; update the two web-player call sites. +5. **`UserHasTrack`:** it duplicates `Track.OwnerUserId` (written only on create). Replace its reads + (`GetTracks`, `GetTracksByUserId`, `GetGenres`, `GenerateMixesForUser`) with `Tracks` filtered + by owner/active, then drop the table (migration). If the intent was a future "library" (saved + tracks), write that in the Log and keep it, but then it must not be written on create. +6. **Mix texts:** move the Spanish titles out of `GenerateMixesForUser` (B.2 rule 13): store a + `MixKind` enum (`Discovery`, `Daily`) instead of title/subtitle strings and let the web-player + render the text. Migration + contract change. **Decision (recommended default): do it**; if + not, at least move the strings to a single constants class. +7. `GenerateMixesForUser`: rely on cascade delete for `MixItems` (remove the manual item query). + +--- + +### P10: Infrastructure services + +**Tasks** +1. `StorageService`: after P4 it no longer depends on `IDatabase`; register it as singleton. + Use `StorageKey.Combine` for keys; fix the `trasnsferUtility` typo and dispose it; fix + `Path.Combine(sourceDirectory, file)` (file is already a full path); prefix normalisation and + pagination helpers shared by `ListObjectsAsync`; no log-and-rethrow (B.2 rule 5); consistent + log levels (`Debug` for per-object operations). `GetFileAsync` returns `null` on `NoSuchKey` + (used by the cover endpoints, P3.5). `IStorageService`: `CancellationToken` last + (`preventOverwrite` before it), parameter names camelCase. +2. `AudioTranscoderService.cs`: rename the class to `AudioTranscoderService` (file already has + that name). Access modifiers, no log-and-rethrow, one `ExecuteFfmpegAsync(arguments, workingDirectory, Stream? input, …)` + instead of two near-identical methods. +3. `PictureService`: no log-and-rethrow. +4. `MassTransitEventBus`: expression-bodied, no `nameof`. +5. **Jobs:** one mechanism. Recommended: **Hangfire for all four** (it already runs in the Worker, + gives retries and a dashboard). Convert `UploadIntentExpirationJob` and + `TemporalUploadsCleanUpJob` (rename file/class to `TempUploadsCleanupJob`) to Hangfire recurring + jobs with the configured intervals, and move their logic behind commands in Application + (`ExpireUploadIntentsCommand`, `DeleteStaleTempUploadsCommand`) like + `DeleteStaleUncountedListensCommand`. Rename `AddDailyMixGenerationJob` to `AddJobs` and + register all jobs there. Job recurring registration moves out of `Worker/Program.cs` into an + extension in Infrastructure. +6. Observability: `AddObservability` also used by the Worker (P13). +7. `JwtBearerEventsHandler` (Api): dispose `HttpRequestMessage`/response; read the userinfo + endpoint from the OIDC discovery document instead of hardcoding `/oidc/v1/userinfo`. + +--- + +### P11: MassTransit deduplication + +**Prerequisites:** P5 (`ImageSizes`), P6 (`IHasLifeCycle`). + +**Tasks** +1. `RoutingSlips` helper (B.3.9); rewrite every builder and failed-consumer with it. Delete + `EndpointHelper`. +2. Merge `AlbumPictureSourceRoutingSlipBuilder` and `PlayListPictureSourceRoutingSlipBuilder` + into `PictureSourceRoutingSlipBuilder` (the only difference is the publish-activity + arguments; pass a factory or a `CoverOwner`). +3. Generic lifecycle activities: `MarkLifeCycleActivity` (arguments: `Guid Id`, + `LifeCycleStatus Status`) and `DeleteEntityActivity` for `Track`, `PlayList`, `Album`. + Replaces `Mark{Track,PlayList,Album}As{Failed,Removing}Activity` and + `Delete{Track,PlayList}FromDbActivity` (+ their arguments). Keep distinct endpoint names per + entity (`mark-track-lifecycle`, …) so queues stay separate. Message contract change (A.2). +4. `Update{Track,PlayList,Album}PictureActivity`: one generic base + `UpdatePicturesActivity` with an abstract "apply/restore pictures" hook; Track + additionally sets `ProcessingStatus`. Compensation for Album/PlayList should also leave a + consistent state (today only Track marks failed). +5. `*ProcessingFailedConsumer`: one shared method building the clean-up slip from + `(entity, bucket, keys[])`. +6. The 9 one-line consumers (`Create*Consumer`, `Update*Consumer`, `Delete*Consumer`) stay one + class per message (MassTransit needs it) but become expression-bodied with no blank lines. +7. The three Audio activities repeat "load track or throw, set status failed on error": extract + `TrackAudioStatus.MarkFailedAsync(database, trackId, ct)`. +8. `GeneratePictureWorkflowPathsActivity` isn't async: return `Task.FromResult`. +9. DI: outbox configuration in one local function (used by client and consumers); saga repository + configuration in one local function (used 3×); keep registration lists alphabetised. +10. `ThrowIfNull` without `nameof`; injected services named after their type (`storageService`, + `pictureService`). + +**Closing criteria:** `Infrastructure/MassTransit` line count down ≥ 30 %; manual end-to-end run: +create/update/delete track, album and playlist, plus a forced failure for each. + +--- + +### P12: API layer + +**Tasks** +1. `Program.cs`: `WebApplication.CreateBuilder(args)`; sorted usings; CORS constant at top. +2. Cover endpoints: one `CoverEndpoint.Stream(...)` helper used by the three `/cover` routes. +3. Routes: parameters named `{id}` for the resource itself and `{trackId}` for sub-resources; + Guid routes use `:guid`, user ids `:long`. `PlayList` routes keep the `/playlists` path. +4. Every endpoint returns `IResult` through `ToHttpResult` (no raw return types), including the + ones whose handlers can't fail (use `Results.Ok`). +5. Update DTOs: drop the `New…` prefix (`UpdateAlbumRequest(Title, Description, ReleaseYear, PictureIntentId)`) + — contract change, regenerate web-player; or keep and write the reason in the Log. + **Decision (recommended default): rename**, it's the only place with that prefix. +6. Validators: one shared upload validator (P1.4 created the allow-lists); album and playlist + picture validators become one generic `PictureUploadRequestValidator`, or merge the DTOs + into `PictureUploadRequest` (preferred, since the responses merged in P8). +7. `OpenApiOptionsSetup` and `ScalarOptionsSetup`: share the `SecuritySchemeId` constant. +8. `.WithSummary(...)` strings: sentence case, no trailing period, consistently. +9. `JwtBearerOptionsSetup`: single-line guard without braces (B.1.1 `when_multiline`). +10. Move the validation limits into one `Hosts/Musify.Api/Validators/Limits.cs` (playlist name + 50, descriptions 256, titles 200, user name 48, release year range) used by every validator, + and list them in the Log so web-player P7.1 copies the same values (A.4). If the two plans + disagree on a limit (e.g. the web-player draft has playlist description 300 and track title + 100), the database column length wins. + +--- + +### P13: Worker and Gateway hosts + +**Tasks** +1. Worker `Program.cs`: call `AddApplication(configuration)` instead of registering Application + options by hand (it currently forgets `AlbumConfiguration`); add `AddObservability`; remove + `ValidateOnBuild = false` and make the container validate. If a handler has dependencies the + Worker can't provide (stream tickets, gateway config), register those configs too — they're + just POCOs — rather than disabling validation. +2. Worker `appsettings.json`: complete and consistent with the API for shared sections (after P5 + the keys changed), remove stale ones. +3. Gateway: `Program.cs` uses `AddValidatedOptions`-style registration (it doesn't reference + Application; copy the 6-line helper locally, or reference nothing and keep + `AddOptions…ValidateOnStart` — **decision (recommended default): keep it local and + self-contained**, the gateway must stay dependency-free); field naming per B.1.1 (no + underscores; rename the middleware field that shadows the `options` constructor parameter); + `appsettings.json` default bucket in the YARP transform is overridden by compose — replace it + with an obviously-placeholder value (`"/buckets/CHANGE_ME/{**rest}"`) so the override is + explicit, or document it in `docs/streaming.md`. + +--- + +### P14: Tests + +**Tasks** +1. Add tests for what has none today: MassTransit activities (at least the generic lifecycle and + picture-update activities with the SQLite `TestDatabase`), `ProcessingSlipFaultConsumer` + (every process kind), routing slip builders (activity order and arguments), saga state + machines with MassTransit's test harness (happy path, failure, missing instance on update). +2. Endpoint tests for visibility/ownership (P1, P3) if not already added there. +3. Keep `TestDatabase` in sync with `Database.OnModelCreating`: extract the owned-type and index + configuration of `Database` into `ModelConfiguration.Apply(ModelBuilder)` in Infrastructure + and call it from both, so the test model can't drift. (Requires `Application.Tests` to + reference Infrastructure, or move the test DB to `Infrastructure.Tests`; pick the former and + note it.) + +--- + +### P15: Final sweep and documentation + +**Tasks** +1. Recompute the A.5 metrics (script below) and fill D.1. +2. Grep for leftovers: `Error.Unauthorized(`, `StaticPagedList`, `new ImageSize(`, + `ThrowIfNull(.*nameof`, `#pragma warning disable CS8618`, `try` blocks around + `SaveChangesAsync` in `Application/`, `serviceDescriptors`, `storageHandler`, `PlaylistVisibility`. +3. Update `docs/architecture.md`, `docs/media-processing.md`, `docs/projects.md`, + `docs/storage.md`, `docs/streaming.md` with the new names (jobs, entities, routes). +4. Add a short `backend/CLAUDE.md` with section B.2 (conventions) so future work follows them. +5. Keep this document as the record: don't delete section E, just make sure every row points to + a checked part in D. + +Metrics script (from `backend/`, Git Bash): + +```bash +F=$(git ls-files | grep "\.cs$" | grep -v Migrations | grep -v Tests); cat $F | wc -l; grep -c "new StaticPagedList" $F | awk -F: '{s+=$2} END {print s}'; grep -c "new ImageSize(" $F | awk -F: '{s+=$2} END {print s}' +``` + +--- + +## D. Log + +| Part | Done | Date | Notes (decisions, contract changes, migrations, queue drains) | +|---|---|---|---| +| P0 Formatting and guardrails | [x] | 2026-09-24 | `.editorconfig` + analyzers added. Rules downgraded to `suggestion` (need behaviour/API changes, revisit in P15): CA1848, CA1873, CA1305, CA1707, CA1859, CA1716, CA1000, CA1725, CA1816, CA1822, CA1852, CA1068, CA1711, CA1861. 350 tests green (Docker available). | +| P1 Security and privacy | [x] | 2026-09-24 | Deleted POST /users, GET /playlists; /playlists/users/{id} lost onlyPublic (owner sees all, others public); /playlists/{id}/tracks hides non-visible playlists; intent purpose checked, same intent for picture+audio rejected; upload validators use allow-lists in Validators/Uploads.cs; gateway replaces multi/invalid Range and rejects suffix ranges with 416 when maxBytes is set; listening-stats now requires auth and self (403 otherwise). Decisions: /playlists/{id}/cover stays public; listening-history and last-listened-track stay public. Contract changes: web-player schema regenerated, onlyPublic call removed. Test helper ApiTestFixture.SeedUserAsync replaces POST /users. | +| P2 Pipeline bugs | [x] | 2026-09-24 | Album picture failures now reported; TempRootPrefix/TempUploadsRetentionDays/TempCleanupJobIntervalSeconds class properties renamed to match appsettings (default value temp); create track/album/playlist slips have fault subscriptions (new process kinds TrackCreation/AlbumCreation/PlayListCreation, slips carry Bucket/PictureKey/AudioKey variables so cleanup works); update-source slips fault as TrackPicture-style (AlbumPicture/PlayListPicture kinds); duration stored as double; empty audio folder key now throws; sagas discard picture/audio events for missing instances. NOT verified: manual stack run with RabbitMQ (no local stack); objects under the old temporal/ prefix in the bucket, if any, need a one-off clean-up. Queues should be drained before deploy (new variable names/process kinds). | +| P3 Query and API bugs | [x] | 2026-09-24 | All 12 tasks done. Contract changes: 403 (ProblemDetails) instead of 401 for not-owner; NotFound/Conflict now ProblemDetails; page params validated (pageNumber>=1, pageSize 1..100, bound through Api PageQuery, default size 20) and /albums/recent limit 1..50 -> 400; /users/{id}/last-listened-track returns 204 when empty; DELETE /albums/{id} is now async (Removing + DeleteAlbumEvent + DeleteAlbumRoutingSlipBuilder). Web-player: schema regenerated, fetchAllPages helper replaces pageSize 200/500 calls. Numbering decision: playlist positions 0-based (renumbered on removal), album track numbers 1-based (already renumbered). Migration PlayListTrackUnique (dedupes rows, unique index). Covers: resized names stay null until processed (AlbumPictures/PlayListPictures.Pending, cover falls back to original, S3 NoSuchKey -> GetFileAsync returns null -> 404). PageRequest helper + AppErrors + TrySaveChangesAsync created here (rest of P7 helpers still pending). Queues: new DeleteAlbumEvent, drain not required. | +| P4 Dead code | [x] | 2026-09-24 | All 12 tasks. Merged the two migrations into one: DropUploadTableAndTrackAudioRetryColumns. Removed SingleFlightCache (+test, Caching.Memory), IStorageService.GetUrlAsync/RemoveFolderAsync, IAudioTranscoderService.IsValidAudioFileAsync, DownloadFileFromUrl activity/args/log, Upload entity/UploadState/IDatabase.Uploads and StorageService DB writes (StorageService no longer depends on IDatabase), CurrentUser.HasClaim/FindClaim/Principal. Newtonsoft.Json pin moved to Infrastructure.csproj (Hangfire pulls a vulnerable 11.0.1 transitively; removing it from Api/Worker triggered NU1903). Api/Worker lost their redundant package references. AppIDatabase alias removed (Database now names Musify.Application.Contracts.IDatabase in full because it clashes with EF Storage.IDatabase). Kept AddUploadIntentConfiguration (Worker needs it until P13). Worker UserSecretsId left as is. Removed AudioTranscoder/Workers from Api appsettings, Worker AudioTranscoder.Routes.WorkingDirectory, and the matching env vars in deploy/compose.yml. docs/architecture.md and development.md updated. | +| P5 Configuration | [x] | 2026-09-24 | Pictures.cs (PictureSize, PictureRoutes, PictureSizes, IPictureOwnerConfiguration, PictureSizeParser), ImageSizes, IConfigurationOptions (static abstract SectionName; every config POCO implements it) and AddValidatedOptions(services, configuration) added; all inline/RegisterValidatedOptions copies removed. Config key renames: ParentFolders->ParentFolder, ProcessedAudioFolder->ProcessedAudiosFolder, Picture -> (Api and Worker appsettings updated, nothing in deploy/ used them). BuildTempAudioPath/BuildTempPicturePath -> BuildTempPath; BuildSmall/Medium/LargePicturePath -> BuildPicturePath(PictureSize, name); the un-prefixed BuildOriginalPicturePath(name) overload is gone. Events still carry Small/Medium/Large ImageSize (ImageSizes in events comes in P8.4). serviceDescriptors renamed to services. Gateway: StreamTicketValidationOptions -> StreamTicketValidationConfiguration, injected as POCO. TrackRoutes keeps PresetXPicturePath props (still used). | +| P6 Domain | [x] | 2026-09-24 | EntityPictures replaces AlbumPictures/PlayListPictures (Pending factory); IHasLifeCycle and IOwnedEntity live in Domain/Abstractions (Domain cannot reference Application); PlayList.UserId -> OwnerUserId ([Column(UserId)]) and User nav -> Owner; entity noise removed (redundant Required/initializers/pragma, nav = null!, Track.Owner no longer required; CreateTrack still loads the user because the response needs the artist name); PlaylistVisibility -> PlayListVisibility (OpenAPI schema name changed, web-player regenerated, check clean); explicit Table attributes with current names (tables NOT renamed); UploadIntent.IsExpired -> IsExpiredAt(now). Verified with an empty migration (deleted); only the model snapshot changed. No schema migration. | +| P7 Application helpers | [x] | 2026-09-24 | Pagination.cs (PageRequest, PaginatedResponse, ToPaginatedAsync), TextNormalizer, AppErrors, OwnershipExtensions.FindOwnedAsync (treats non-active as NotFound; IOwnedEntity has no Id, IHasLifeCycle supplies it), Track/Album/PlayListProjections. X.PagedList removed. GetTracks/GetAlbums return PaginatedResponse directly (contract: no more track/album wrapper; web-player updated). List queries keep int PageNumber/PageSize in the query record and build PageRequest inside the handler. PlayListApplicationResponse gained ownerUserId, trackCount, durationSeconds (web-player P3.1/P8.1 unblocked). Plain return types for GetUsers, GetLikedTracks, GetAlbums, GetTracks, GetPlayListsByUserId, GetRecentlyListenedAlbums, GetMixesByUserId, GetListeningHistory (IReadOnlyList). Inline responses moved to Responses/ (cover locations merge in P8). Create/Update album+playlist, Request*Upload, CreateTrack and RequestTrackUploadUrls keep their try/catch until they are rewritten in P8/P9. | +| P8 Pictures feature | [x] | 2026-09-24 | UploadIntentIssuer (IssueAsync/IssueOneAsync/IssuePictureAsync: user check, quota in a serializable transaction, presigned URLs issued AFTER the quota check) shared by album/playlist/track upload commands; PictureUploadResponse replaces AlbumPictureUploadResponse/PlayListPictureUploadResponse (OpenAPI schema renamed, web-player regenerated). GetCoverQuery(CoverOwner, Id, PictureSize)+CoverLocation replace the three cover queries; API uses CoverEndpoint.Stream helper (done here instead of P12.2). PictureSourceChange.PrepareAsync used by create/update album+playlist; events use the intent Bucket. UploadIntentValidator now injects UploadIntentConfiguration; quota is one aggregate query, uses the audio default for audio intents and ignores intents already past ExpiresAt. Events and Publish*Arguments carry ImageSizes Sizes (message contract change: drain queues before deploying). Update/Create handlers dropped their try/catch. | +| P9 Responsibilities | [x] | 2026-09-24 | StartListeningCommand replaces GetTrackStreamQuery (creates the ListeningHistory); TrackStreamIssuer is DB-free. LongAsString converters live in Api/Serialization and are registered globally (OpenAPI transformer marks response longs as string, request longs stay integer|string; web-player schema types unchanged). Tag rules only in CreateTrackRequestValidator, RecordListeningProgressRequestValidator added, genre bound with GenreParameter (400 on unknown). POST /playlists/{id}/tracks and POST /albums/{id}/tracks take { trackIds } (1-500), skip tracks already present and answer 204 (single-track routes removed; adding a duplicate is now a silent success, no 409); web-player updated, album->playlist uses one request. UserHasTrack dropped (migration DropUserHasTrack; reads use Tracks.OwnerUserId). Mix stores MixKind instead of Spanish text (migration MixKind maps the old daily title to Daily); MixApplicationResponse now has kind, web-player renders the texts (data/mixes.ts). GenerateMixesForUser relies on cascade delete. Decision on mix texts: done. | +| P10 Infrastructure services | [x] | 2026-09-24 | StorageService is a singleton without log-and-rethrow (GetFileAsync null on NoSuchKey), IStorageService.GetUploadUrlAsync now (bucket,key,contentType,expiry,preventOverwrite,ct). AudioService -> AudioTranscoderService with a single ExecuteFfmpegAsync. PictureService and MassTransitEventBus simplified. Jobs: all four are Hangfire recurring jobs registered by RecurringJobsRegistrar (hosted service) inside AddJobs (was AddDailyMixGenerationJob); logic lives in ExpireUploadIntentsCommand / DeleteStaleTempUploadsCommand; TemporalUploadsCleanUpJob renamed TempUploadsCleanupJob. Hangfire cron has 1-minute resolution, so the configured *Seconds intervals are rounded up to whole minutes (UploadIntent ExpirationJobIntervalSeconds, TempCleanupJobIntervalSeconds). GenerateMixesForUser is now a plain ICommand. JwtBearerEventsHandler disposes request/response and reads the userinfo endpoint from the OIDC discovery document (falls back to /oidc/v1/userinfo). Observability for the Worker is wired in the Worker part. | +| P11 MassTransit | [x] | 2026-09-24 | RoutingSlips helper (Create/AddStep/TrackFaults/AddRemoveFile) replaces the slip preamble and EndpointHelper; RoutingSlipVariableNames moved to the RoutingSlip namespace. Merged builders: PictureSourceRoutingSlipBuilder (album+playlist) and DeleteRoutingSlipBuilder (track/album/playlist). Generic MarkLifeCycleActivity/DeleteEntityActivity and UpdatePicturesActivity with one thin subclass per entity (separate queues kept; IDatabase gained Set()). Album/playlist picture updates now return a compensation log. ProcessingFailedCleanup shared by the three failed consumers; TrackAudioStatus shared by the audio activities; consumers are expression-bodied; DI uses AddOutbox/ConfigureSagaRepository local helpers. MassTransit folder 3547 -> ~2530 lines (-29%). Contract change: new activity endpoints (mark-*-life-cycle, delete-*, update-*-picture args/logs) - drain queues before deploying. Not done: the remaining log-and-rethrow in the Files activities, and the manual end-to-end run (no local stack). | +| P12 API layer | [x] | 2026-09-24 | Routes: resource is `{id:guid}`, sub-resource `{trackId:guid}`, user ids `{id:long}`/`{userId:long}` (OpenApiOptionsSetup maps int64 path params to string so the web-player keeps passing string ids). Contract changes: path params renamed (`albumId`/`playlistId` -> `id`, DELETE /tracks/{id}), `New*` prefix dropped from UpdateAlbumRequest/UpdatePlayListRequest, album+playlist picture DTOs/validators merged into PictureUploadRequest(+Validator), listening-stats 403 is now ProblemDetails; malformed guid/long routes now 404. Every endpoint returns IResult. Summaries in sentence case. Program: CreateBuilder(args) and RouteHandlerOptions.ThrowOnBadRequest=false (with args the test host runs as Development, which would turn bad query params into 500s). Validators/Limits.cs (web-player P7.1 must copy): track title 200, album title 200, playlist name 50, description 256 (album and playlist), release year 1877..current year+1, user name 48 (Domain only, no API validator), max 500 tracks per add request. Web-player: schema regenerated, call sites and update bodies adapted, `npm run check` clean. 365 tests green. | +| P13 Worker and Gateway | [x] | 2026-09-24 | Worker Program uses AddApplication + AddObservability + AddStreamTicketService and the container now validates (ValidateOnBuild and ValidateScopes on); checked by running the Worker in Development, it builds and starts the MassTransit endpoints. Because every Application handler is registered, the Worker also needs `Album`, `StreamGateway`, `StreamTicket` (key never read) and `Playback` sections: Worker appsettings.json now mirrors the Api for shared sections, appsettings.Development.json got the stream settings, deploy/compose.yml gives the worker StreamGateway__PublicBaseUrl, StreamTicket__PrivateKeyPath, the keys volume and keys-init dependency. IEventBus is now registered by AddMassTransitConsumers too. AddUploadIntentConfiguration removed. Gateway: kept its own options registration, no underscore fields (TicketValidator lost its unused `options` field), CreateBuilder(args), CorsPolicy constant, YARP bucket in appsettings.json is `CHANGE_ME` (compose overrides it; appsettings.Development.json carries `webapi-storage` for local runs). 365 tests green. Docs (media-processing.md Worker sections, streaming.md bucket) to update in P15. | +| P14 Tests | [x] | 2026-09-24 | ModelConfiguration.Apply(ModelBuilder) in Infrastructure/Persistence is called by Database and by the Application.Tests TestDatabase (Application.Tests now references Infrastructure; TestDatabase therefore also creates the saga tables). New Infrastructure.Tests (Infrastructure gets InternalsVisibleTo for Infrastructure.Tests and DynamicProxyGenAssembly2 so NSubstitute can mock ExecuteContext over internal argument types): ProcessingSlipFaultConsumer for every process kind plus unknown/missing kind, saga state machines (Track/Album/PlayList happy path, failure, event for a missing instance is discarded) on the MassTransit test harness, routing slip builders (create, audio workflow, the four picture-source slips, DeleteRoutingSlipBuilder against Postgres), lifecycle activities (mark/delete, missing entity is skipped, Postgres fixture instead of SQLite because the fixture already exists there), picture-update activities run inside real routing slips on the harness (execute, missing entity/variable fault the slip, compensation restores names / marks track pictures failed), RecurringJobsRegistrar.EveryCron. Api.Tests: PlayListEndpointsTests (private/public visibility, tracks hidden, per-user list, non-owner update/delete 403, non-guid id 404, name length). Observation: EveryCron maps 60+ minute intervals to whole hours by integer division (5400 s -> every hour), left as is. 432 tests green. | +| P15 Final sweep | [x] | 2026-09-24 | Metrics recomputed (D.1). Leftover greps: `Error.Unauthorized(` only in StartListening ("sign in", intended), `new ImageSize(` only in PictureSizes, no StaticPagedList/serviceDescriptors/storageHandler/PlaylistVisibility/ThrowIfNull(nameof); the two `#pragma warning disable CS8618` in TrackAudio/TrackPictures were unnecessary and removed; no try/catch around SaveChangesAsync in Application. Log-and-rethrow removed from the Copy, Download, Transfer, Upload and Resize activities (Remove keeps its NoSuchKey catch; the bare catch blocks in the audio activities mark the track failed and are not log-and-rethrow). Analyzer overrides revisited: CA1000, CA1725 (param names), CA1816, CA1852, CA1068 (cancellation tokens moved last), CA1711 (only kept off for test collection fixtures), CA1861, CA1305 (InvariantCulture), CA1859 and CA1707 (off only under Tests/) are fixed or scoped; still `suggestion`: CA1848 and CA1873 (would need [LoggerMessage] on ~110 call sites), CA1716 (`IDatabase.Set` and the `Shared` namespace, deliberate) and CA1822 (stateless DI builders). Added a `private static readonly` PascalCase naming rule. `dotnet format` applied (imports order and whitespace only) and `--verify-no-changes` is clean. docs/ (architecture, media-processing, projects, storage, streaming, development, authentication) updated to the new names, and backend/CLAUDE.md added. Temporary ZzOpenApiDumpTests deleted (to regenerate the web-player schema again, run the API in Development and export /openapi/v1.json). Every row of section E points to a part that is now checked. 431 tests green (432 with the temporary dump test); NOT verified: a manual end-to-end run of the full stack (RabbitMQ, SeaweedFS, Zitadel) after the refactor. Deploy: drain the queues first (message contracts changed in P2, P8, P11, P3) and apply the migrations `PlayListTrackUnique`, `DropUploadTableAndTrackAudioRetryColumns`, `DropUserHasTrack`, `MixKind`; the worker now needs `StreamGateway__PublicBaseUrl` and `StreamTicket__PrivateKeyPath` (+ keys volume), already added to deploy/compose.yml. | + +### D.1 Recalculated metrics (after P15) + +| Metric | Before | After | +|---|---|---| +| Non-migration `.cs` lines (excluding tests) | 12,687 | 10,424 (-17.9 %, below the -25 to -30 % target: the plan's dedup was done, the rest is behaviour the audit added such as bulk endpoints, ownership helpers, mix kinds and Hangfire jobs). MassTransit 3,547 -> 2,482, Application 3,811 | +| `new StaticPagedList` | 7 | 0 | +| `new ImageSize(` | 15 | 3 (inside `PictureSizes`) | +| Files with block namespace | 249 | 0 (306 file-scoped; 4 files have no namespace: AssemblyInfo and the three Program.cs) | + +--- + +## E. Findings index (audit 2026-09-24 → part) + +### E.1 Bugs + +| # | Finding | Where | Part | +|---|---|---|---| +| 1 | `POST /users` is anonymous; anyone can create any user id | `UserEndpoints.cs:91` | P1.1 | +| 2 | `GET /playlists` lists every user's private playlists | `PlayListEndpoints.cs:22`, `GetPlayLists.cs` | P1.2 | +| 3 | `onlyPublic=false` default exposes other users' private playlists | `PlayListEndpoints.cs:135` | P1.2 | +| 4 | Playlist tracks endpoint ignores visibility | `GetPlayListTracks.cs` | P1.2 | +| 5 | Upload intent purpose not checked; same intent usable for picture and audio | `UploadIntentValidator.cs:45`, `CreateTrack.cs` | P1.3 | +| 6 | `FileType`/`ContentType` unrestricted; long extension overflows `varchar(64)` → 500 | upload validators | P1.4 | +| 7 | Gateway passes invalid/multi `Range` unclamped → anonymous preview bypass | `TicketValidationMiddleware.cs:84` | P1.5 | +| 8 | Album picture failures never reported; saga stuck in `Processing` | `ProcessingSlipFaultConsumer.cs:22` | P2.1 | +| 9 | `Temp*` config keys don't match `Temporal*` properties → ignored | both `appsettings.json` | P2.2 | +| 10 | Create slips without fault handling → entity `Pending` forever | `CreateTrackRoutingSlipBuilder`, picture-source builders | P2.3 | +| 11 | Duration rounded to int although column is double | `TranscodeAudioActivity.cs:58` | P2.4 | +| 12 | Empty audio folder key publishes "processed" without completing | `UpdateTrackAudioActivity.cs` | P2.5 | +| 13 | (to verify) picture-update events hit finalized sagas → `_error` queues | `Sagas/*` | P2.6 | +| 14 | "Not owner" returns 401 instead of 403 | `ErrorOrHttpExtensions.cs:32` + 10 handlers | P3.1 | +| 15 | `pageSize=0` → 500; no upper bound; `limit` unbounded | all list endpoints | P3.2 | +| 16 | `totalCount` computed before `Active` filter | `GetPlayListsByUserId.cs:41` | P3.3 | +| 17 | Removed/failed rows returned by public reads | many queries | P3.4 | +| 18 | Album/playlist covers 500 until processing finishes | `CreateAlbum.cs:63`, cover queries | P3.5 | +| 19 | Double-click like/follow/add → unique violation 500; playlist tracks can duplicate | likes, follows, `AddTrackTo*`, `Database.cs` | P3.6 | +| 20 | Position `Max+1` race; playlists 0-based without renumber vs albums 1-based with | `AddTrackTo*`, `RemoveTrackFrom*` | P3.7 | +| 21 | Playlist update can't clear description; names not trimmed; invalid enum accepted | `UpdatePlayList.cs`, `CreatePlayList.cs` | P3.8 | +| 22 | N+1 in mixes list | `GetMixesByUserId.cs` | P3.9 | +| 23 | Last-listened counts uncounted listens; 404 when none | `GetLastTrackListenedByUserId.cs` | P3.10 | +| 24 | Streak resets before the day ends | `GetListeningStats.cs` | P3.11 | +| 25 | `DeleteAlbum` leaves pictures in storage | `DeleteAlbum.cs:33` | P3.12 | +| 26 | `Path.Combine(sourceDirectory, file)` with a full path | `StorageService.cs:121` | P10.1 | +| 27 | Userinfo URL hardcoded to Zitadel path | `JwtBearerEventsHandler.cs` | P10.7 | +| 28 | Wrong log message "as processing" on delete | `DeleteTrack.cs:64` | P7.5 (log policy rewrite) | + +### E.2 Endpoints not used by the web-player (free to change) + +`GET /playlists`, `POST /users`, `GET /users/{id}`, `GET /users/{id}/is-following`, +`GET /albums/recent`, `GET /tracks/{id}`. `GET /users/{id}/listening-stats` has one call site; +check it before P1.6. + +### E.3 Quality findings + +| Finding | Part | +|---|---| +| No `.editorconfig`/analyzers; mixed namespaces, BOMs (2 mid-file), missing final newlines, stray spaces, blank lines after `{`, unsorted usings, missing `private` | P0 | +| Private field naming mixed (`_x` vs `x`), field shadowing ctor parameter in gateway middleware | P0, P13 | +| Album ↔ PlayList handler clones (upload, cover, create/update picture parts) | P8 | +| `AlbumRoutes`/`PlayListRoutes`/`TrackRoutes` + 3 `*PicturesSizes`; `ParentFolders` vs `ParentFolder`; identical `BuildTempPicturePath`/`BuildTempAudioPath` | P5 | +| `AlbumPictures` ≡ `PlayListPictures` | P6 | +| 15× `new ImageSize` | P5 | +| 9× cover-ids projection, 9× listens-count projection, 7× `StaticPagedList` | P7 | +| Search wrapper responses with swapped `HasNext/HasPrevious` order | P7 | +| Owner-check boilerplate in ~12 handlers | P7 | +| try/catch around save/publish in ~15 handlers; log-and-rethrow in infrastructure | P7, P10 | +| Inconsistent errors (`NotFound()` with/without description, one code), `Result.Success` vs `new Success()`, log levels | P7 | +| Handlers returning `ErrorOr` that can't fail; lazy `IEnumerable` result | P7 | +| Responses declared inside handler files; `CreateUserCommand.ToEntity` | P7, P1 | +| Inconsistent ordering; pagination clamp in one handler only | P3, P7 | +| Query that writes (`GetTrackStream`) | P9 | +| JSON converters in Application | P9 | +| Duplicated validation (tags, listening progress, genre parsing in endpoint) | P9 | +| Endpoint orchestration in `AddPlayListTrack`; album vs playlist add-track API mismatch | P9 | +| `UserHasTrack` duplicates ownership | P9 | +| Hardcoded Spanish mix titles in Application | P9 | +| `StorageService` writes to DB (`Upload`), typo `trasnsferUtility`, duplicated key building, `CancellationToken` not last, PascalCase parameter | P4, P10 | +| `AudioService` in `AudioTranscoderService.cs`; duplicated ffmpeg execution methods | P10 | +| Two job mechanisms (Hangfire + BackgroundService); job logic in infrastructure vs command; `CleanUp`/`Cleanup` naming; misleading `AddDailyMixGenerationJob` | P10 | +| 8 copies of validated-options registration; `serviceDescriptors` vs `services`; `…Options` vs `…Configuration` | P5 | +| 7 near-identical lifecycle activities; 3 picture-update activities; 2 picture-source builders; 3 failed consumers; routing-slip preamble ×10; outbox ×2; saga repo ×3 | P11 | +| `RoutingSlipVariableNames` namespace ≠ folder | P11 | +| Cover endpoint ×3; route parameter naming; no route constraints; raw return types; `New…` DTO prefix; duplicated upload validators; duplicated `SecuritySchemeId` | P12 | +| Worker: manual options (misses Album), no observability, `ValidateOnBuild = false`, stale appsettings | P13 | +| Entity noise: redundant `[Required]`, initializers, pragma vs `null!`, `required` navigation, mixed table naming, `PlaylistVisibility` casing | P6 | +| Dead code (see P4 list) and redundant package references | P4 | +| No tests for MassTransit activities, builders, sagas; `TestDatabase` drifts from `Database` | P14 |