diff --git a/backend/.editorconfig b/backend/.editorconfig new file mode 100644 index 0000000..8af7303 --- /dev/null +++ b/backend/.editorconfig @@ -0,0 +1,65 @@ +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 + +# Analyzer rules that would need behaviour or API changes; downgraded in P0, revisit in P15. +dotnet_diagnostic.CA1848.severity = suggestion +dotnet_diagnostic.CA1873.severity = suggestion +dotnet_diagnostic.CA1305.severity = suggestion +dotnet_diagnostic.CA1707.severity = suggestion +dotnet_diagnostic.CA1859.severity = suggestion +dotnet_diagnostic.CA1716.severity = suggestion +dotnet_diagnostic.CA1000.severity = suggestion +dotnet_diagnostic.CA1725.severity = suggestion +dotnet_diagnostic.CA1816.severity = suggestion +dotnet_diagnostic.CA1822.severity = suggestion +dotnet_diagnostic.CA1852.severity = suggestion +dotnet_diagnostic.CA1068.severity = suggestion +dotnet_diagnostic.CA1711.severity = suggestion +dotnet_diagnostic.CA1861.severity = suggestion diff --git a/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs b/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs index 219705e..a8c783a 100644 --- a/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs +++ b/backend/Core/Musify.Application/Albums/AddTrackToAlbum.cs @@ -5,78 +5,77 @@ using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Domain.Entities; -namespace Musify.Application.Albums +namespace Musify.Application.Albums; + +public record AddTrackToAlbumCommand(long UserId, Guid AlbumId, Guid TrackId) + : ICommand>; + +public class AddTrackToAlbumCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> { - public record AddTrackToAlbumCommand(long UserId, Guid AlbumId, Guid TrackId) - : ICommand>; - - public class AddTrackToAlbumCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> + public async ValueTask> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken) { - public async ValueTask> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken) + var album = await database.Albums + .AsNoTracking() + .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); + if (album == null) { - var album = await database.Albums - .AsNoTracking() - .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); - if (album == null) - { - logger.LogInformation("Album {AlbumId} not found", request.AlbumId); - return Error.NotFound(); - } - - if (album.OwnerUserId != request.UserId) - { - logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId); - return Error.Unauthorized(); - } - - var track = await database.Tracks - .AsNoTracking() - .SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken); - if (track == null) - { - logger.LogInformation("Track {TrackId} not found", request.TrackId); - return Error.NotFound(description: $"Track {request.TrackId} not found"); - } - - if (track.OwnerUserId != request.UserId) - { - logger.LogWarning("User {UserId} does not own track {TrackId}", request.UserId, request.TrackId); - return Error.Unauthorized(); - } - - var alreadyAdded = await database.AlbumHasTracks - .AsNoTracking() - .AnyAsync(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.TrackId == request.TrackId, cancellationToken); - if (alreadyAdded) - { - logger.LogInformation("Track {TrackId} already in album {AlbumId}", request.TrackId, request.AlbumId); - return Error.Conflict(description: "Track is already in the album."); - } - - var albumTracksQuery = database.AlbumHasTracks - .Where(albumTrack => albumTrack.AlbumId == request.AlbumId); - - var hasTracks = await albumTracksQuery.AnyAsync(cancellationToken); - var nextTrackNumber = hasTracks - ? await albumTracksQuery.MaxAsync(albumTrack => albumTrack.TrackNumber, cancellationToken) + 1 - : 1; - - await database.AlbumHasTracks.AddAsync(new AlbumHasTrack - { - AlbumId = request.AlbumId, - TrackId = request.TrackId, - TrackNumber = nextTrackNumber - }, cancellationToken); - - await database.SaveChangesAsync(cancellationToken); - - logger.LogInformation("Added track {TrackId} to album {AlbumId} as number {TrackNumber}", - request.TrackId, request.AlbumId, nextTrackNumber); - - return new Success(); + logger.LogInformation("Album {AlbumId} not found", request.AlbumId); + return Error.NotFound(); } + + if (album.OwnerUserId != request.UserId) + { + logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId); + return Error.Unauthorized(); + } + + var track = await database.Tracks + .AsNoTracking() + .SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken); + if (track == null) + { + logger.LogInformation("Track {TrackId} not found", request.TrackId); + return Error.NotFound(description: $"Track {request.TrackId} not found"); + } + + if (track.OwnerUserId != request.UserId) + { + logger.LogWarning("User {UserId} does not own track {TrackId}", request.UserId, request.TrackId); + return Error.Unauthorized(); + } + + var alreadyAdded = await database.AlbumHasTracks + .AsNoTracking() + .AnyAsync(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.TrackId == request.TrackId, cancellationToken); + if (alreadyAdded) + { + logger.LogInformation("Track {TrackId} already in album {AlbumId}", request.TrackId, request.AlbumId); + return Error.Conflict(description: "Track is already in the album."); + } + + var albumTracksQuery = database.AlbumHasTracks + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId); + + var hasTracks = await albumTracksQuery.AnyAsync(cancellationToken); + var nextTrackNumber = hasTracks + ? await albumTracksQuery.MaxAsync(albumTrack => albumTrack.TrackNumber, cancellationToken) + 1 + : 1; + + await database.AlbumHasTracks.AddAsync(new AlbumHasTrack + { + AlbumId = request.AlbumId, + TrackId = request.TrackId, + TrackNumber = nextTrackNumber + }, cancellationToken); + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Added track {TrackId} to album {AlbumId} as number {TrackNumber}", + request.TrackId, request.AlbumId, nextTrackNumber); + + return new Success(); } } diff --git a/backend/Core/Musify.Application/Albums/CreateAlbum.cs b/backend/Core/Musify.Application/Albums/CreateAlbum.cs index 842f97c..4646035 100644 --- a/backend/Core/Musify.Application/Albums/CreateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/CreateAlbum.cs @@ -2,127 +2,126 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using Musify.Application.Configuration; -using Musify.Application.Shared; -using Musify.Application.Events; using Musify.Application.Albums.Responses; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Application.Events; using Musify.Application.Services; +using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -using Musify.Application.Contracts; -namespace Musify.Application.Albums +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, + ApplicationStorageConfiguration storageConfiguration, + AlbumConfiguration albumConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) + : ICommandHandler> { - 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, - ApplicationStorageConfiguration storageConfiguration, - AlbumConfiguration albumConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) - : ICommandHandler> + public async ValueTask> Handle(CreateAlbumCommand request, CancellationToken cancellationToken) { - public async ValueTask> Handle(CreateAlbumCommand request, CancellationToken cancellationToken) + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(user => user.Id == request.UserId, cancellationToken); + if (!userExists) { - var userExists = await database.Users - .AsNoTracking() - .AnyAsync(user => user.Id == request.UserId, cancellationToken); - if (!userExists) - { - logger.LogWarning("User {UserId} not found", request.UserId); - return Error.NotFound(description: $"User {request.UserId} not found"); - } - - var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, request.PictureIntentId, request.UserId, cancellationToken); - if (pictureIntent.IsError) - return pictureIntent.Errors; - - var intent = pictureIntent.Value; - var title = request.Title.Trim(); - - var album = new Album - { - OwnerUserId = request.UserId, - Title = title, - NormalizedTitle = title.ToUpperInvariant(), - Description = request.Description, - ReleaseYear = request.ReleaseYear, - Pictures = new AlbumPictures - { - OriginalName = intent.ObjectName, - SmallName = intent.ObjectName, - MediumName = intent.ObjectName, - LargeName = intent.ObjectName - } - }; - - await database.Albums.AddAsync(album, cancellationToken); - - var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName); - var publishResult = await PublishCreateAlbumEventAsync(album.Id, intent, finalPictureKey, cancellationToken); - if (publishResult.IsError) - return publishResult.Errors; - - try - { - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to save album for user {UserId}", request.UserId); - return Error.Failure(description: $"Failed to create album for user {request.UserId}"); - } - - logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId); - - return AlbumApplicationResponse.FromEntity(album, trackCount: 0); + logger.LogWarning("User {UserId} not found", request.UserId); + return Error.NotFound(description: $"User {request.UserId} not found"); } - private async Task> PublishCreateAlbumEventAsync( - Guid albumId, - UploadIntent pictureIntent, - string finalPictureKey, - CancellationToken cancellationToken) + var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync( + uploadIntentConfiguration, request.PictureIntentId, request.UserId, cancellationToken); + if (pictureIntent.IsError) + return pictureIntent.Errors; + + var intent = pictureIntent.Value; + var title = request.Title.Trim(); + + var album = new Album { - try + OwnerUserId = request.UserId, + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + Description = request.Description, + ReleaseYear = request.ReleaseYear, + Pictures = new AlbumPictures { - await eventBus.PublishAsync( - new CreateAlbumResourcesEvent( - albumId, - pictureIntent.Id, - storageConfiguration.Bucket, - pictureIntent.Key, - finalPictureKey, - new ImageSize( - albumConfiguration.Routes.SmallPicturesPath, - albumConfiguration.PicturesSizes.SmallPictureWidth, - albumConfiguration.PicturesSizes.SmallPictureHeight), - new ImageSize( - albumConfiguration.Routes.MediumPicturesPath, - albumConfiguration.PicturesSizes.MediumPictureWidth, - albumConfiguration.PicturesSizes.MediumPictureHeight), - new ImageSize( - albumConfiguration.Routes.LargePicturesPath, - albumConfiguration.PicturesSizes.LargePictureWidth, - albumConfiguration.PicturesSizes.LargePictureHeight)), - cancellationToken); - return new Success(); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to publish create album event for album {AlbumId}", albumId); - return Error.Failure(description: $"Failed to publish create album event for album {albumId}"); + OriginalName = intent.ObjectName, + SmallName = intent.ObjectName, + MediumName = intent.ObjectName, + LargeName = intent.ObjectName } + }; + + await database.Albums.AddAsync(album, cancellationToken); + + var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName); + var publishResult = await PublishCreateAlbumEventAsync(album.Id, intent, finalPictureKey, cancellationToken); + if (publishResult.IsError) + return publishResult.Errors; + + try + { + await database.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to save album for user {UserId}", request.UserId); + return Error.Failure(description: $"Failed to create album for user {request.UserId}"); + } + + logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId); + + return AlbumApplicationResponse.FromEntity(album, trackCount: 0); + } + + private async Task> PublishCreateAlbumEventAsync( + Guid albumId, + UploadIntent pictureIntent, + string finalPictureKey, + CancellationToken cancellationToken) + { + try + { + await eventBus.PublishAsync( + new CreateAlbumResourcesEvent( + albumId, + pictureIntent.Id, + storageConfiguration.Bucket, + pictureIntent.Key, + finalPictureKey, + new ImageSize( + albumConfiguration.Routes.SmallPicturesPath, + albumConfiguration.PicturesSizes.SmallPictureWidth, + albumConfiguration.PicturesSizes.SmallPictureHeight), + new ImageSize( + albumConfiguration.Routes.MediumPicturesPath, + albumConfiguration.PicturesSizes.MediumPictureWidth, + albumConfiguration.PicturesSizes.MediumPictureHeight), + new ImageSize( + albumConfiguration.Routes.LargePicturesPath, + albumConfiguration.PicturesSizes.LargePictureWidth, + albumConfiguration.PicturesSizes.LargePictureHeight)), + cancellationToken); + return new Success(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to publish create album event for album {AlbumId}", albumId); + return Error.Failure(description: $"Failed to publish create album event for album {albumId}"); } } } diff --git a/backend/Core/Musify.Application/Albums/DeleteAlbum.cs b/backend/Core/Musify.Application/Albums/DeleteAlbum.cs index 182a1fb..11b1bf8 100644 --- a/backend/Core/Musify.Application/Albums/DeleteAlbum.cs +++ b/backend/Core/Musify.Application/Albums/DeleteAlbum.cs @@ -4,47 +4,46 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; -namespace Musify.Application.Albums +namespace Musify.Application.Albums; + +public record DeleteAlbumCommand(long UserId, Guid AlbumId) + : ICommand>; + +public class DeleteAlbumCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> { - public record DeleteAlbumCommand(long UserId, Guid AlbumId) - : ICommand>; - - public class DeleteAlbumCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> + public async ValueTask> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken) { - public async ValueTask> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken) + var album = await database.Albums + .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); + if (album == null) { - var album = await database.Albums - .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); - if (album == null) - { - logger.LogDebug("Album {AlbumId} not found", request.AlbumId); - return Error.NotFound(); - } - - if (album.OwnerUserId != request.UserId) - { - logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId); - return Error.Unauthorized(); - } - - database.Albums.Remove(album); - - try - { - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to delete album {AlbumId}", request.AlbumId); - return Error.Failure(description: $"Failed to delete album {request.AlbumId}"); - } - - logger.LogInformation("Deleted album {AlbumId} for user {UserId}", request.AlbumId, request.UserId); - - return new Success(); + logger.LogDebug("Album {AlbumId} not found", request.AlbumId); + return Error.NotFound(); } + + if (album.OwnerUserId != request.UserId) + { + logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId); + return Error.Unauthorized(); + } + + database.Albums.Remove(album); + + try + { + await database.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to delete album {AlbumId}", request.AlbumId); + return Error.Failure(description: $"Failed to delete album {request.AlbumId}"); + } + + logger.LogInformation("Deleted album {AlbumId} for user {UserId}", request.AlbumId, request.UserId); + + return new Success(); } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumById.cs b/backend/Core/Musify.Application/Albums/GetAlbumById.cs index 97e92c2..03991cb 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumById.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumById.cs @@ -4,35 +4,34 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; -namespace Musify.Application.Albums +namespace Musify.Application.Albums; + +public record GetAlbumByIdQuery(Guid Id) + : IQuery>; + +public class GetAlbumByIdQueryHandler(IDatabase database) + : IQueryHandler> { - public record GetAlbumByIdQuery(Guid Id) - : IQuery>; - - public class GetAlbumByIdQueryHandler(IDatabase database) - : IQueryHandler> + public async ValueTask> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken) { - public async ValueTask> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken) - { - var album = await database.Albums - .AsNoTracking() - .Where(a => a.Id == request.Id) - .Select(a => new - { - Album = a, - TrackCount = a.AlbumTracks.Count, - CoverTrackIds = a.AlbumTracks - .OrderBy(albumTrack => albumTrack.TrackNumber) - .Take(AlbumApplicationResponse.CoverTrackCount) - .Select(albumTrack => albumTrack.TrackId) - .ToList() - }) - .SingleOrDefaultAsync(cancellationToken); + var album = await database.Albums + .AsNoTracking() + .Where(a => a.Id == request.Id) + .Select(a => new + { + Album = a, + TrackCount = a.AlbumTracks.Count, + CoverTrackIds = a.AlbumTracks + .OrderBy(albumTrack => albumTrack.TrackNumber) + .Take(AlbumApplicationResponse.CoverTrackCount) + .Select(albumTrack => albumTrack.TrackId) + .ToList() + }) + .SingleOrDefaultAsync(cancellationToken); - if (album == null) - return Error.NotFound(); + if (album == null) + return Error.NotFound(); - return AlbumApplicationResponse.FromEntity(album.Album, album.TrackCount, album.CoverTrackIds); - } + return AlbumApplicationResponse.FromEntity(album.Album, album.TrackCount, album.CoverTrackIds); } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumCover.cs b/backend/Core/Musify.Application/Albums/GetAlbumCover.cs index 0ade1b8..333f206 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumCover.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumCover.cs @@ -5,45 +5,44 @@ using MimeMapping; using Musify.Application.Configuration; using Musify.Application.Contracts; -namespace Musify.Application.Albums +namespace Musify.Application.Albums; + +public record GetAlbumCoverQuery(Guid AlbumId, string Size) + : IQuery>; + +public record AlbumCoverLocation(string Bucket, string Key, string ContentType); + +public class GetAlbumCoverQueryHandler( + IDatabase database, + ApplicationStorageConfiguration storageConfiguration, + AlbumConfiguration albumConfiguration) + : IQueryHandler> { - public record GetAlbumCoverQuery(Guid AlbumId, string Size) - : IQuery>; - - public record AlbumCoverLocation(string Bucket, string Key, string ContentType); - - public class GetAlbumCoverQueryHandler( - IDatabase database, - ApplicationStorageConfiguration storageConfiguration, - AlbumConfiguration albumConfiguration) - : IQueryHandler> + public async ValueTask> Handle(GetAlbumCoverQuery request, CancellationToken cancellationToken) { - public async ValueTask> Handle(GetAlbumCoverQuery request, CancellationToken cancellationToken) + var pictures = await database.Albums.AsNoTracking() + .Where(a => a.Id == request.AlbumId) + .Select(a => a.Pictures) + .SingleOrDefaultAsync(cancellationToken); + + if (pictures == null) + return Error.NotFound(); + + var routes = albumConfiguration.Routes; + var (name, key) = request.Size.ToLowerInvariant() switch { - var pictures = await database.Albums.AsNoTracking() - .Where(a => a.Id == request.AlbumId) - .Select(a => a.Pictures) - .SingleOrDefaultAsync(cancellationToken); + "small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)), + "large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)), + _ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName)) + }; - if (pictures == null) - return Error.NotFound(); + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) + return Error.NotFound(); - var routes = albumConfiguration.Routes; - var (name, key) = request.Size.ToLowerInvariant() switch - { - "small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)), - "large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)), - _ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName)) - }; - - if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) - return Error.NotFound(); - - var contentType = MimeUtility.GetMimeMapping(name); - return new AlbumCoverLocation(storageConfiguration.Bucket, key, contentType); - } - - private static string? BuildKey(Func builder, string? name) - => string.IsNullOrEmpty(name) ? null : builder(name); + var contentType = MimeUtility.GetMimeMapping(name); + return new AlbumCoverLocation(storageConfiguration.Bucket, key, contentType); } + + private static string? BuildKey(Func builder, string? name) + => string.IsNullOrEmpty(name) ? null : builder(name); } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs index fbee9b8..1b5ed0d 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs @@ -7,43 +7,42 @@ using Musify.Application.Tracks.Responses; using X.PagedList; using X.PagedList.EF; -namespace Musify.Application.Albums +namespace Musify.Application.Albums; + +public record GetAlbumTracksQuery( + Guid AlbumId, + int PageNumber, + int PageSize) + : IQuery>>; + +public class GetAlbumTracksQueryHandler(IDatabase database) + : IQueryHandler>> { - public record GetAlbumTracksQuery( - Guid AlbumId, - int PageNumber, - int PageSize) - : IQuery>>; - - public class GetAlbumTracksQueryHandler(IDatabase database) - : IQueryHandler>> + public async ValueTask>> Handle(GetAlbumTracksQuery request, CancellationToken cancellationToken) { - public async ValueTask>> Handle(GetAlbumTracksQuery request, CancellationToken cancellationToken) - { - var albumExists = await database.Albums - .AsNoTracking() - .AnyAsync(album => album.Id == request.AlbumId, cancellationToken); - if (!albumExists) - return Error.NotFound(); + var albumExists = await database.Albums + .AsNoTracking() + .AnyAsync(album => album.Id == request.AlbumId, cancellationToken); + if (!albumExists) + return Error.NotFound(); - var tracksQuery = database.AlbumHasTracks - .AsNoTracking() - .Include(albumTrack => albumTrack.Track.Owner) - .Include(albumTrack => albumTrack.Track.Tags) - .Where(albumTrack => albumTrack.AlbumId == request.AlbumId); + var tracksQuery = database.AlbumHasTracks + .AsNoTracking() + .Include(albumTrack => albumTrack.Track.Owner) + .Include(albumTrack => albumTrack.Track.Tags) + .Where(albumTrack => albumTrack.AlbumId == request.AlbumId); - var totalCount = await tracksQuery.CountAsync(cancellationToken); + var totalCount = await tracksQuery.CountAsync(cancellationToken); - var pagedEntities = await tracksQuery - .OrderBy(albumTrack => albumTrack.TrackNumber) - .Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count(l => l.IsCounted) }) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + var pagedEntities = await tracksQuery + .OrderBy(albumTrack => albumTrack.TrackNumber) + .Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count(l => l.IsCounted) }) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - var pagedTracks = new StaticPagedList( - pagedEntities.Select(entry => TrackApplicationResponse.FromEntity(entry.Track, entry.ListensCount)), - pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); + var pagedTracks = new StaticPagedList( + pagedEntities.Select(entry => TrackApplicationResponse.FromEntity(entry.Track, entry.ListensCount)), + pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); - return PaginatedResponse.FromPagedList(pagedTracks); - } + return PaginatedResponse.FromPagedList(pagedTracks); } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbums.cs b/backend/Core/Musify.Application/Albums/GetAlbums.cs index f149d35..b8d64e9 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbums.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbums.cs @@ -3,58 +3,56 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; -using X.PagedList; using X.PagedList.EF; -namespace Musify.Application.Albums +namespace Musify.Application.Albums; + +public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize) + : IQuery>; + +public class GetAlbumsQueryHandler(IDatabase database) + : IQueryHandler> { - public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize) - : IQuery>; - - public class GetAlbumsQueryHandler(IDatabase database) - : IQueryHandler> + public async ValueTask> Handle(GetAlbumsQuery request, CancellationToken cancellationToken) { - public async ValueTask> Handle(GetAlbumsQuery request, CancellationToken cancellationToken) + var albumsQuery = database.Albums + .AsNoTracking() + .AsQueryable(); + + if (!string.IsNullOrEmpty(request.Title)) { - var albumsQuery = database.Albums - .AsNoTracking() - .AsQueryable(); - - if (!string.IsNullOrEmpty(request.Title)) - { - var normalizedTitle = request.Title.Trim().ToUpperInvariant(); - albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle)); - } - - var totalCount = await albumsQuery.CountAsync(cancellationToken); - - var pagedEntities = await albumsQuery - .OrderByDescending(album => album.CreatedAt) - .Select(album => new - { - Album = album, - TrackCount = album.AlbumTracks.Count, - CoverTrackIds = album.AlbumTracks - .OrderBy(albumTrack => albumTrack.TrackNumber) - .Take(AlbumApplicationResponse.CoverTrackCount) - .Select(albumTrack => albumTrack.TrackId) - .ToList() - }) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - - var items = pagedEntities - .Select(entry => AlbumSearchItemResponse.FromAlbum( - AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds))) - .ToList(); - - return new AlbumsSearchResponse( - items, - pagedEntities.PageNumber, - pagedEntities.PageSize, - pagedEntities.PageCount, - pagedEntities.TotalItemCount, - pagedEntities.HasPreviousPage, - pagedEntities.HasNextPage); + var normalizedTitle = request.Title.Trim().ToUpperInvariant(); + albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle)); } + + var totalCount = await albumsQuery.CountAsync(cancellationToken); + + var pagedEntities = await albumsQuery + .OrderByDescending(album => album.CreatedAt) + .Select(album => new + { + Album = album, + TrackCount = album.AlbumTracks.Count, + CoverTrackIds = album.AlbumTracks + .OrderBy(albumTrack => albumTrack.TrackNumber) + .Take(AlbumApplicationResponse.CoverTrackCount) + .Select(albumTrack => albumTrack.TrackId) + .ToList() + }) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + + var items = pagedEntities + .Select(entry => AlbumSearchItemResponse.FromAlbum( + AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds))) + .ToList(); + + return new AlbumsSearchResponse( + items, + pagedEntities.PageNumber, + pagedEntities.PageSize, + pagedEntities.PageCount, + pagedEntities.TotalItemCount, + pagedEntities.HasPreviousPage, + pagedEntities.HasNextPage); } } diff --git a/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs b/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs index 8b7b5b2..9f8c829 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumsByUserId.cs @@ -7,47 +7,46 @@ using Musify.Application.Shared; using X.PagedList; using X.PagedList.EF; -namespace Musify.Application.Albums +namespace Musify.Application.Albums; + +public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize) + : IQuery>>; + +public class GetAlbumsByUserIdQueryHandler(IDatabase database) + : IQueryHandler>> { - public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize) - : IQuery>>; - - public class GetAlbumsByUserIdQueryHandler(IDatabase database) - : IQueryHandler>> + public async ValueTask>> Handle(GetAlbumsByUserIdQuery request, CancellationToken cancellationToken) { - public async ValueTask>> Handle(GetAlbumsByUserIdQuery request, CancellationToken cancellationToken) - { - var userExists = await database.Users - .AsNoTracking() - .AnyAsync(user => user.Id == request.UserId, cancellationToken); - if (!userExists) - return Error.NotFound(description: $"User {request.UserId} not found"); + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(user => user.Id == request.UserId, cancellationToken); + if (!userExists) + return Error.NotFound(description: $"User {request.UserId} not found"); - var albumsQuery = database.Albums - .AsNoTracking() - .Where(album => album.OwnerUserId == request.UserId); + var albumsQuery = database.Albums + .AsNoTracking() + .Where(album => album.OwnerUserId == request.UserId); - var totalCount = await albumsQuery.CountAsync(cancellationToken); + var totalCount = await albumsQuery.CountAsync(cancellationToken); - var pagedEntities = await albumsQuery - .OrderByDescending(album => album.CreatedAt) - .Select(album => new - { - Album = album, - TrackCount = album.AlbumTracks.Count, - CoverTrackIds = album.AlbumTracks - .OrderBy(albumTrack => albumTrack.TrackNumber) - .Take(AlbumApplicationResponse.CoverTrackCount) - .Select(albumTrack => albumTrack.TrackId) - .ToList() - }) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + var pagedEntities = await albumsQuery + .OrderByDescending(album => album.CreatedAt) + .Select(album => new + { + Album = album, + TrackCount = album.AlbumTracks.Count, + CoverTrackIds = album.AlbumTracks + .OrderBy(albumTrack => albumTrack.TrackNumber) + .Take(AlbumApplicationResponse.CoverTrackCount) + .Select(albumTrack => albumTrack.TrackId) + .ToList() + }) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - var pagedAlbums = new StaticPagedList( - pagedEntities.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)), - pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); + var pagedAlbums = new StaticPagedList( + pagedEntities.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)), + pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); - return PaginatedResponse.FromPagedList(pagedAlbums); - } + return PaginatedResponse.FromPagedList(pagedAlbums); } } diff --git a/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs b/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs index b46190d..4eeb1e4 100644 --- a/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs +++ b/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs @@ -4,58 +4,57 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Albums.Responses; using Musify.Application.Contracts; -namespace Musify.Application.Albums +namespace Musify.Application.Albums; + +public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit) + : IQuery>>; + +public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database) + : IQueryHandler>> { - public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit) - : IQuery>>; - - public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database) - : IQueryHandler>> + public async ValueTask>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken) { - public async ValueTask>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken) - { - var listenedTrackIds = database.ListeningHistories - .AsNoTracking() - .Where(history => history.UserId == request.UserId && history.IsCounted); + var listenedTrackIds = database.ListeningHistories + .AsNoTracking() + .Where(history => history.UserId == request.UserId && history.IsCounted); - var ranked = await database.AlbumHasTracks - .AsNoTracking() - .Join( - listenedTrackIds, - 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) - .Take(request.Limit) - .ToListAsync(cancellationToken); + var ranked = await database.AlbumHasTracks + .AsNoTracking() + .Join( + listenedTrackIds, + 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) + .Take(request.Limit) + .ToListAsync(cancellationToken); - if (ranked.Count == 0) - return new List(); + if (ranked.Count == 0) + return new List(); - var albumIds = ranked.Select(entry => entry.AlbumId).ToList(); + var albumIds = ranked.Select(entry => entry.AlbumId).ToList(); - var albums = await database.Albums - .AsNoTracking() - .Where(album => albumIds.Contains(album.Id)) - .Select(album => new - { - Album = album, - TrackCount = album.AlbumTracks.Count, - CoverTrackIds = album.AlbumTracks - .OrderBy(albumTrack => albumTrack.TrackNumber) - .Take(AlbumApplicationResponse.CoverTrackCount) - .Select(albumTrack => albumTrack.TrackId) - .ToList() - }) - .ToDictionaryAsync(entry => entry.Album.Id, cancellationToken); + var albums = await database.Albums + .AsNoTracking() + .Where(album => albumIds.Contains(album.Id)) + .Select(album => new + { + Album = album, + TrackCount = album.AlbumTracks.Count, + CoverTrackIds = album.AlbumTracks + .OrderBy(albumTrack => albumTrack.TrackNumber) + .Take(AlbumApplicationResponse.CoverTrackCount) + .Select(albumTrack => albumTrack.TrackId) + .ToList() + }) + .ToDictionaryAsync(entry => entry.Album.Id, cancellationToken); - return ranked - .Where(entry => albums.ContainsKey(entry.AlbumId)) - .Select(entry => albums[entry.AlbumId]) - .Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)) - .ToList(); - } + return ranked + .Where(entry => albums.ContainsKey(entry.AlbumId)) + .Select(entry => albums[entry.AlbumId]) + .Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)) + .ToList(); } } diff --git a/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs b/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs index d913840..a626a88 100644 --- a/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs +++ b/backend/Core/Musify.Application/Albums/RemoveTrackFromAlbum.cs @@ -4,56 +4,55 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; -namespace Musify.Application.Albums +namespace Musify.Application.Albums; + +public record RemoveTrackFromAlbumCommand(long UserId, Guid AlbumId, Guid TrackId) + : ICommand>; + +public class RemoveTrackFromAlbumCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> { - public record RemoveTrackFromAlbumCommand(long UserId, Guid AlbumId, Guid TrackId) - : ICommand>; - - public class RemoveTrackFromAlbumCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> + public async ValueTask> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken) { - public async ValueTask> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken) + var album = await database.Albums + .AsNoTracking() + .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); + if (album == null) { - var album = await database.Albums - .AsNoTracking() - .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); - if (album == null) - { - logger.LogInformation("Album {AlbumId} not found", request.AlbumId); - return Error.NotFound(); - } - - if (album.OwnerUserId != request.UserId) - { - logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId); - return Error.Unauthorized(); - } - - 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) - { - logger.LogInformation("Track {TrackId} not in album {AlbumId}", request.TrackId, request.AlbumId); - return Error.NotFound(description: "Track is not in the album."); - } - - database.AlbumHasTracks.Remove(link); - - var trackNumber = 1; - foreach (var remaining in albumTracks.Where(albumTrack => albumTrack.Id != link.Id)) - remaining.TrackNumber = trackNumber++; - - await database.SaveChangesAsync(cancellationToken); - - logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId); - - return new Success(); + logger.LogInformation("Album {AlbumId} not found", request.AlbumId); + return Error.NotFound(); } + + if (album.OwnerUserId != request.UserId) + { + logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId); + return Error.Unauthorized(); + } + + 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) + { + logger.LogInformation("Track {TrackId} not in album {AlbumId}", request.TrackId, request.AlbumId); + return Error.NotFound(description: "Track is not in the album."); + } + + database.AlbumHasTracks.Remove(link); + + var trackNumber = 1; + foreach (var remaining in albumTracks.Where(albumTrack => albumTrack.Id != link.Id)) + remaining.TrackNumber = trackNumber++; + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId); + + return new Success(); } } diff --git a/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs b/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs index e0f0abc..c273345 100644 --- a/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs +++ b/backend/Core/Musify.Application/Albums/RequestAlbumPictureUpload.cs @@ -2,104 +2,103 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using Musify.Application.Configuration; using Musify.Application.Albums.Responses; +using Musify.Application.Configuration; +using Musify.Application.Contracts; using Musify.Application.Services; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -using Musify.Application.Contracts; -namespace Musify.Application.Albums +namespace Musify.Application.Albums; + +public record RequestAlbumPictureUploadCommand( + long UserId, + string FileType, + string ContentType, + long? ExpectedSizeBytes = null) + : ICommand>; + +public class RequestAlbumPictureUploadCommandHandler( + IDatabase database, + UploadIntentValidator uploadIntentValidator, + IStorageService storageService, + ILogger logger, + ApplicationStorageConfiguration storageConfiguration, + AlbumConfiguration albumConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) + : ICommandHandler> { - public record RequestAlbumPictureUploadCommand( - long UserId, - string FileType, - string ContentType, - long? ExpectedSizeBytes = null) - : ICommand>; - - public class RequestAlbumPictureUploadCommandHandler( - IDatabase database, - UploadIntentValidator uploadIntentValidator, - IStorageService storageService, - ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - AlbumConfiguration albumConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) - : ICommandHandler> + public async ValueTask> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) { - public async ValueTask> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(u => u.Id == request.UserId, cancellationToken); + if (!userExists) { - var userExists = await database.Users - .AsNoTracking() - .AnyAsync(u => u.Id == request.UserId, cancellationToken); - if (!userExists) + logger.LogWarning("User {UserId} not found", request.UserId); + return Error.NotFound(description: $"User {request.UserId} not found"); + } + + var effectiveSizeBytes = request.ExpectedSizeBytes + ?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes; + + var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}"; + + var tempKey = albumConfiguration.Routes.BuildTempPicturePath( + uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName); + + try + { + var uploadUrl = await storageService.GetUploadUrlAsync( + storageConfiguration.Bucket, + tempKey, + request.ContentType, + TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds), + cancellationToken); + + await using var transaction = await database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, cancellationToken); + + var quotaCheck = await uploadIntentValidator.CheckQuotaAsync( + uploadIntentConfiguration, + request.UserId, effectiveSizeBytes, 1, cancellationToken); + if (quotaCheck.IsError) { - logger.LogWarning("User {UserId} not found", request.UserId); - return Error.NotFound(description: $"User {request.UserId} not found"); + logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId); + return quotaCheck.Errors; } - var effectiveSizeBytes = request.ExpectedSizeBytes - ?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes; - - var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}"; - - var tempKey = albumConfiguration.Routes.BuildTempPicturePath( - uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName); - - try + var intent = new UploadIntent { - var uploadUrl = await storageService.GetUploadUrlAsync( - storageConfiguration.Bucket, - tempKey, - request.ContentType, - TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds), - cancellationToken); + UserId = request.UserId, + Bucket = storageConfiguration.Bucket, + Key = tempKey, + ObjectName = objectName, + ContentType = request.ContentType, + ExpectedSizeBytes = effectiveSizeBytes, + Purpose = UploadIntentPurpose.AlbumPicture, + ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds), + }; - await using var transaction = await database.BeginTransactionAsync( - System.Data.IsolationLevel.Serializable, cancellationToken); + await database.UploadIntents.AddAsync(intent, cancellationToken); + await database.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); - var quotaCheck = await uploadIntentValidator.CheckQuotaAsync( - uploadIntentConfiguration, - request.UserId, effectiveSizeBytes, 1, cancellationToken); - if (quotaCheck.IsError) - { - logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId); - return quotaCheck.Errors; - } + logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (AlbumPicture, temp key: {Key})", intent.Id, request.UserId, tempKey); - var intent = new UploadIntent - { - UserId = request.UserId, - Bucket = storageConfiguration.Bucket, - Key = tempKey, - ObjectName = objectName, - ContentType = request.ContentType, - ExpectedSizeBytes = effectiveSizeBytes, - Purpose = UploadIntentPurpose.AlbumPicture, - ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds), - }; - - await database.UploadIntents.AddAsync(intent, cancellationToken); - await database.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); - - logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (AlbumPicture, temp key: {Key})", intent.Id, request.UserId, tempKey); - - return new AlbumPictureUploadResponse( - intent.Id, - storageConfiguration.Bucket, - tempKey, - objectName, - request.ContentType, - uploadIntentConfiguration.UploadUrlExpiresInSeconds, - uploadUrl); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to generate album picture upload URL for user {UserId}", request.UserId); - return Error.Failure(description: "Failed to generate upload URL"); - } + return new AlbumPictureUploadResponse( + intent.Id, + storageConfiguration.Bucket, + tempKey, + objectName, + request.ContentType, + uploadIntentConfiguration.UploadUrlExpiresInSeconds, + uploadUrl); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to generate album picture upload URL for user {UserId}", request.UserId); + return Error.Failure(description: "Failed to generate upload URL"); } } } diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs index ea03647..cf5731b 100644 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumApplicationResponse.cs @@ -1,43 +1,42 @@ using System.Text.Json.Serialization; -using Musify.Domain.Entities; using Musify.Application.Serialization; +using Musify.Domain.Entities; -namespace Musify.Application.Albums.Responses +namespace Musify.Application.Albums.Responses; + +public record AlbumApplicationResponse( + Guid Id, + string Title, + string? Description, + int? ReleaseYear, + [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, + int TrackCount, + string? SmallImageKeyName, + string? MediumImageKeyName, + string? LargeImageKeyName, + DateTime CreatedAt, + DateTime UpdatedAt, + IReadOnlyList CoverTrackIds) { - public record AlbumApplicationResponse( - Guid Id, - string Title, - string? Description, - int? ReleaseYear, - [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, - int TrackCount, - string? SmallImageKeyName, - string? MediumImageKeyName, - string? LargeImageKeyName, - DateTime CreatedAt, - DateTime UpdatedAt, - IReadOnlyList CoverTrackIds) - { - public const int CoverTrackCount = 4; + public const int CoverTrackCount = 4; - 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 ?? []); - } + 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/AlbumPictureUploadResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumPictureUploadResponse.cs index ab3dcd3..bc39921 100644 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumPictureUploadResponse.cs +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumPictureUploadResponse.cs @@ -1,11 +1,10 @@ -namespace Musify.Application.Albums.Responses -{ - public record AlbumPictureUploadResponse( - Guid IntentId, - string Bucket, - string Key, - string PictureName, - string ContentType, - int ExpiresInSeconds, - string UploadUrl); -} +namespace Musify.Application.Albums.Responses; + +public record AlbumPictureUploadResponse( + Guid IntentId, + string Bucket, + string Key, + string PictureName, + string ContentType, + int ExpiresInSeconds, + string UploadUrl); diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs index b587b80..600494b 100644 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumSearchItemResponse.cs @@ -1,7 +1,6 @@ -namespace Musify.Application.Albums.Responses +namespace Musify.Application.Albums.Responses; + +public record AlbumSearchItemResponse(AlbumApplicationResponse Album) { - public record AlbumSearchItemResponse(AlbumApplicationResponse Album) - { - public static AlbumSearchItemResponse FromAlbum(AlbumApplicationResponse album) => new(album); - } + public static AlbumSearchItemResponse FromAlbum(AlbumApplicationResponse album) => new(album); } diff --git a/backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs b/backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs index ea5c64c..aa88d5b 100644 --- a/backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs +++ b/backend/Core/Musify.Application/Albums/Responses/AlbumsSearchResponse.cs @@ -1,12 +1,11 @@ -namespace Musify.Application.Albums.Responses -{ +namespace Musify.Application.Albums.Responses; - public record AlbumsSearchResponse( - IReadOnlyList Items, - int PageNumber, - int PageSize, - int PageCount, - int TotalItemCount, - bool HasPreviousPage, - bool HasNextPage); -} + +public record AlbumsSearchResponse( + IReadOnlyList Items, + int PageNumber, + int PageSize, + int PageCount, + int TotalItemCount, + bool HasPreviousPage, + bool HasNextPage); diff --git a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs index d89500e..1ceb9d9 100644 --- a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs @@ -2,149 +2,148 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using Musify.Application.Configuration; -using Musify.Application.Shared; -using Musify.Application.Events; using Musify.Application.Albums.Responses; +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Application.Events; using Musify.Application.Services; +using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -using Musify.Application.Contracts; -namespace Musify.Application.Albums +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, + ApplicationStorageConfiguration storageConfiguration, + AlbumConfiguration albumConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) + : ICommandHandler> { - 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, - ApplicationStorageConfiguration storageConfiguration, - AlbumConfiguration albumConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) - : ICommandHandler> + public async ValueTask> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken) { - public async ValueTask> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken) + var album = await database.Albums + .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); + if (album == null) { - var album = await database.Albums - .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken); - if (album == null) - { - logger.LogInformation("Album {AlbumId} not found", request.AlbumId); - return Error.NotFound(); - } - - if (album.OwnerUserId != request.UserId) - { - logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId); - return Error.Unauthorized(); - } - - var title = request.Title.Trim(); - - album.Title = title; - album.NormalizedTitle = title.ToUpperInvariant(); - album.Description = request.Description; - album.ReleaseYear = request.ReleaseYear; - - UploadIntent? pictureIntent = null; - string? finalPictureKey = null; - - if (request.NewPictureIntentId.HasValue) - { - var validation = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, - request.NewPictureIntentId.Value, request.UserId, cancellationToken); - if (validation.IsError) - return validation.Errors; - - pictureIntent = validation.Value; - album.Pictures = new AlbumPictures - { - OriginalName = pictureIntent.ObjectName, - SmallName = pictureIntent.ObjectName, - MediumName = pictureIntent.ObjectName, - LargeName = pictureIntent.ObjectName - }; - finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); - } - - if (pictureIntent != null && finalPictureKey != null) - { - var publishResult = await PublishUpdateAlbumPictureSourceEventAsync( - album.Id, pictureIntent, finalPictureKey, cancellationToken); - if (publishResult.IsError) - return publishResult.Errors; - } - - try - { - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to update album {AlbumId}", request.AlbumId); - return Error.Failure(description: $"Failed to update album {request.AlbumId}"); - } - - var albumTracks = database.AlbumHasTracks - .AsNoTracking() - .Where(albumTrack => albumTrack.AlbumId == album.Id); - - var trackCount = await albumTracks.CountAsync(cancellationToken); - var coverTrackIds = await albumTracks - .OrderBy(albumTrack => albumTrack.TrackNumber) - .Take(AlbumApplicationResponse.CoverTrackCount) - .Select(albumTrack => albumTrack.TrackId) - .ToListAsync(cancellationToken); - - logger.LogInformation("Updated album {AlbumId}", album.Id); - return AlbumApplicationResponse.FromEntity(album, trackCount, coverTrackIds); + logger.LogInformation("Album {AlbumId} not found", request.AlbumId); + return Error.NotFound(); } - private async Task> PublishUpdateAlbumPictureSourceEventAsync( - Guid albumId, - UploadIntent pictureIntent, - string finalPictureKey, - CancellationToken cancellationToken) + if (album.OwnerUserId != request.UserId) { - try + logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId); + return Error.Unauthorized(); + } + + var title = request.Title.Trim(); + + album.Title = title; + album.NormalizedTitle = title.ToUpperInvariant(); + album.Description = request.Description; + album.ReleaseYear = request.ReleaseYear; + + UploadIntent? pictureIntent = null; + string? finalPictureKey = null; + + if (request.NewPictureIntentId.HasValue) + { + var validation = await uploadIntentValidator.ValidateAndLoadAsync( + uploadIntentConfiguration, + request.NewPictureIntentId.Value, request.UserId, cancellationToken); + if (validation.IsError) + return validation.Errors; + + pictureIntent = validation.Value; + album.Pictures = new AlbumPictures { - await eventBus.PublishAsync( - new UpdateAlbumPictureSourceEvent( - albumId, - pictureIntent.Id, - storageConfiguration.Bucket, - pictureIntent.Key, - finalPictureKey, - new ImageSize( - albumConfiguration.Routes.SmallPicturesPath, - albumConfiguration.PicturesSizes.SmallPictureWidth, - albumConfiguration.PicturesSizes.SmallPictureHeight), - new ImageSize( - albumConfiguration.Routes.MediumPicturesPath, - albumConfiguration.PicturesSizes.MediumPictureWidth, - albumConfiguration.PicturesSizes.MediumPictureHeight), - new ImageSize( - albumConfiguration.Routes.LargePicturesPath, - albumConfiguration.PicturesSizes.LargePictureWidth, - albumConfiguration.PicturesSizes.LargePictureHeight)), - cancellationToken); - return new Success(); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to publish update album picture source event for album {AlbumId}", albumId); - return Error.Failure(description: $"Failed to publish update album picture source event for album {albumId}"); - } + OriginalName = pictureIntent.ObjectName, + SmallName = pictureIntent.ObjectName, + MediumName = pictureIntent.ObjectName, + LargeName = pictureIntent.ObjectName + }; + finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); + } + + if (pictureIntent != null && finalPictureKey != null) + { + var publishResult = await PublishUpdateAlbumPictureSourceEventAsync( + album.Id, pictureIntent, finalPictureKey, cancellationToken); + if (publishResult.IsError) + return publishResult.Errors; + } + + try + { + await database.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to update album {AlbumId}", request.AlbumId); + return Error.Failure(description: $"Failed to update album {request.AlbumId}"); + } + + var albumTracks = database.AlbumHasTracks + .AsNoTracking() + .Where(albumTrack => albumTrack.AlbumId == album.Id); + + var trackCount = await albumTracks.CountAsync(cancellationToken); + var coverTrackIds = await albumTracks + .OrderBy(albumTrack => albumTrack.TrackNumber) + .Take(AlbumApplicationResponse.CoverTrackCount) + .Select(albumTrack => albumTrack.TrackId) + .ToListAsync(cancellationToken); + + logger.LogInformation("Updated album {AlbumId}", album.Id); + return AlbumApplicationResponse.FromEntity(album, trackCount, coverTrackIds); + } + + private async Task> PublishUpdateAlbumPictureSourceEventAsync( + Guid albumId, + UploadIntent pictureIntent, + string finalPictureKey, + CancellationToken cancellationToken) + { + try + { + await eventBus.PublishAsync( + new UpdateAlbumPictureSourceEvent( + albumId, + pictureIntent.Id, + storageConfiguration.Bucket, + pictureIntent.Key, + finalPictureKey, + new ImageSize( + albumConfiguration.Routes.SmallPicturesPath, + albumConfiguration.PicturesSizes.SmallPictureWidth, + albumConfiguration.PicturesSizes.SmallPictureHeight), + new ImageSize( + albumConfiguration.Routes.MediumPicturesPath, + albumConfiguration.PicturesSizes.MediumPictureWidth, + albumConfiguration.PicturesSizes.MediumPictureHeight), + new ImageSize( + albumConfiguration.Routes.LargePicturesPath, + albumConfiguration.PicturesSizes.LargePictureWidth, + albumConfiguration.PicturesSizes.LargePictureHeight)), + cancellationToken); + return new Success(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to publish update album picture source event for album {AlbumId}", albumId); + return Error.Failure(description: $"Failed to publish update album picture source event for album {albumId}"); } } } diff --git a/backend/Core/Musify.Application/AssemblyInfo.cs b/backend/Core/Musify.Application/AssemblyInfo.cs index 845ff9b..f8c51ca 100644 --- a/backend/Core/Musify.Application/AssemblyInfo.cs +++ b/backend/Core/Musify.Application/AssemblyInfo.cs @@ -1,4 +1,4 @@ -using Mediator; +using Mediator; using Microsoft.Extensions.DependencyInjection; -[assembly: MediatorOptions(ServiceLifetime = ServiceLifetime.Scoped)] \ No newline at end of file +[assembly: MediatorOptions(ServiceLifetime = ServiceLifetime.Scoped)] diff --git a/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs b/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs index 1802775..a505c99 100644 --- a/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/AlbumConfiguration.cs @@ -1,79 +1,78 @@ using System.ComponentModel.DataAnnotations; using Musify.Application.Shared; -namespace Musify.Application.Configuration +namespace Musify.Application.Configuration; + +public class AlbumConfiguration { - public class AlbumConfiguration - { - public const string SectionName = "Album"; + public const string SectionName = "Album"; - [Required] - public AlbumRoutes Routes { get; set; } = new AlbumRoutes(); + [Required] + public AlbumRoutes Routes { get; set; } = new AlbumRoutes(); - [Required] - public AlbumPicturesSizes PicturesSizes { get; set; } = new AlbumPicturesSizes(); - } + [Required] + public AlbumPicturesSizes PicturesSizes { get; set; } = new AlbumPicturesSizes(); +} - public class AlbumRoutes - { - [Required] - public string UploadsFolder { get; set; } = "uploads"; +public class AlbumRoutes +{ + [Required] + public string UploadsFolder { get; set; } = "uploads"; - [Required] - public string ParentFolders { get; set; } = "Albums"; + [Required] + public string ParentFolders { get; set; } = "Albums"; - [Required] - public string OriginalPicturesFolder { get; set; } = "OriginalPictures"; + [Required] + public string OriginalPicturesFolder { get; set; } = "OriginalPictures"; - [Required] - public string SmallPicturesFolder { get; set; } = "SmallPictures"; + [Required] + public string SmallPicturesFolder { get; set; } = "SmallPictures"; - [Required] - public string MediumPicturesFolder { get; set; } = "MediumPictures"; + [Required] + public string MediumPicturesFolder { get; set; } = "MediumPictures"; - [Required] - public string LargePicturesFolder { get; set; } = "LargePictures"; + [Required] + public string LargePicturesFolder { get; set; } = "LargePictures"; - public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder); + public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder); - public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder); + public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder); - public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder); + public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder); - public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder); + public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder); - public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName); + public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName); - public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName); + public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName); - public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName); + public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName); - public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName); + public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName); - public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName); + public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName); - public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) => - StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName); - } + public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) => + StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName); +} - public class AlbumPicturesSizes - { - [Range(1, 1024)] - public int SmallPictureWidth { get; set; } = 128; +public class AlbumPicturesSizes +{ + [Range(1, 1024)] + public int SmallPictureWidth { get; set; } = 128; - [Range(1, 1024)] - public int SmallPictureHeight { get; set; } = 128; + [Range(1, 1024)] + public int SmallPictureHeight { get; set; } = 128; - [Range(1, 1024)] - public int MediumPictureWidth { get; set; } = 256; + [Range(1, 1024)] + public int MediumPictureWidth { get; set; } = 256; - [Range(1, 1024)] - public int MediumPictureHeight { get; set; } = 256; + [Range(1, 1024)] + public int MediumPictureHeight { get; set; } = 256; - [Range(1, 1024)] - public int LargePictureWidth { get; set; } = 512; + [Range(1, 1024)] + public int LargePictureWidth { get; set; } = 512; - [Range(1, 1024)] - public int LargePictureHeight { get; set; } = 512; - } + [Range(1, 1024)] + public int LargePictureHeight { get; set; } = 512; } diff --git a/backend/Core/Musify.Application/Configuration/ApplicationStorageConfiguration.cs b/backend/Core/Musify.Application/Configuration/ApplicationStorageConfiguration.cs index 1458cc3..b402a36 100644 --- a/backend/Core/Musify.Application/Configuration/ApplicationStorageConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/ApplicationStorageConfiguration.cs @@ -1,13 +1,12 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Application.Configuration -{ - public class ApplicationStorageConfiguration - { - public const string SectionName = "ApplicationStorage"; +namespace Musify.Application.Configuration; - [Required] - [StringLength(128, MinimumLength = 1)] - public required string Bucket { get; set; } - } +public class ApplicationStorageConfiguration +{ + public const string SectionName = "ApplicationStorage"; + + [Required] + [StringLength(128, MinimumLength = 1)] + public required string Bucket { get; set; } } diff --git a/backend/Core/Musify.Application/Configuration/GetPlaybackPublicConfig.cs b/backend/Core/Musify.Application/Configuration/GetPlaybackPublicConfig.cs index 8b96121..e731ae2 100644 --- a/backend/Core/Musify.Application/Configuration/GetPlaybackPublicConfig.cs +++ b/backend/Core/Musify.Application/Configuration/GetPlaybackPublicConfig.cs @@ -1,17 +1,16 @@ using Mediator; using Musify.Application.Configuration.Responses; -namespace Musify.Application.Configuration +namespace Musify.Application.Configuration; + + +public record GetPlaybackPublicConfigQuery : IQuery; + +public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration) + : IQueryHandler { - - 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)); - } + public ValueTask Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) => + ValueTask.FromResult(new PlaybackPublicConfigResponse( + playbackConfiguration.AllowAnonymousListening, + playbackConfiguration.AnonymousFragmentSeconds)); } diff --git a/backend/Core/Musify.Application/Configuration/MixConfiguration.cs b/backend/Core/Musify.Application/Configuration/MixConfiguration.cs index 5c99baf..af33d2c 100644 --- a/backend/Core/Musify.Application/Configuration/MixConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/MixConfiguration.cs @@ -1,12 +1,11 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Application.Configuration -{ - public class MixConfiguration - { - public const string SectionName = "Mix"; +namespace Musify.Application.Configuration; - [Range(4, 100)] - public int ItemsPerMix { get; set; } = 12; - } +public class MixConfiguration +{ + public const string SectionName = "Mix"; + + [Range(4, 100)] + public int ItemsPerMix { get; set; } = 12; } diff --git a/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs b/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs index cb1ea32..10b7051 100644 --- a/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/PlayListConfiguration.cs @@ -1,79 +1,78 @@ using System.ComponentModel.DataAnnotations; using Musify.Application.Shared; -namespace Musify.Application.Configuration +namespace Musify.Application.Configuration; + +public class PlayListConfiguration { - public class PlayListConfiguration - { - public const string SectionName = "PlayList"; + public const string SectionName = "PlayList"; - [Required] - public PlayListRoutes Routes { get; set; } = new PlayListRoutes(); + [Required] + public PlayListRoutes Routes { get; set; } = new PlayListRoutes(); - [Required] - public PlayListPicturesSizes PicturesSizes { get; set; } = new PlayListPicturesSizes(); - } + [Required] + public PlayListPicturesSizes PicturesSizes { get; set; } = new PlayListPicturesSizes(); +} - public class PlayListRoutes - { - [Required] - public string UploadsFolder { get; set; } = "uploads"; +public class PlayListRoutes +{ + [Required] + public string UploadsFolder { get; set; } = "uploads"; - [Required] - public string ParentFolders { get; set; } = "PlayLists"; + [Required] + public string ParentFolders { get; set; } = "PlayLists"; - [Required] - public string OriginalPicturesFolder { get; set; } = "OriginalPictures"; + [Required] + public string OriginalPicturesFolder { get; set; } = "OriginalPictures"; - [Required] - public string SmallPicturesFolder { get; set; } = "SmallPictures"; + [Required] + public string SmallPicturesFolder { get; set; } = "SmallPictures"; - [Required] - public string MediumPicturesFolder { get; set; } = "MediumPictures"; + [Required] + public string MediumPicturesFolder { get; set; } = "MediumPictures"; - [Required] - public string LargePicturesFolder { get; set; } = "LargePictures"; + [Required] + public string LargePicturesFolder { get; set; } = "LargePictures"; - public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder); + public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder); - public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder); + public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder); - public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder); + public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder); - public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder); + public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder); - public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName); + public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName); - public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName); + public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName); - public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName); + public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName); - public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName); + public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName); - public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName); + public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName); - public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) => - StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName); - } + public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) => + StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName); +} - public class PlayListPicturesSizes - { - [Range(1, 1024)] - public int SmallPictureWidth { get; set; } = 128; +public class PlayListPicturesSizes +{ + [Range(1, 1024)] + public int SmallPictureWidth { get; set; } = 128; - [Range(1, 1024)] - public int SmallPictureHeight { get; set; } = 128; + [Range(1, 1024)] + public int SmallPictureHeight { get; set; } = 128; - [Range(1, 1024)] - public int MediumPictureWidth { get; set; } = 256; + [Range(1, 1024)] + public int MediumPictureWidth { get; set; } = 256; - [Range(1, 1024)] - public int MediumPictureHeight { get; set; } = 256; + [Range(1, 1024)] + public int MediumPictureHeight { get; set; } = 256; - [Range(1, 1024)] - public int LargePictureWidth { get; set; } = 512; + [Range(1, 1024)] + public int LargePictureWidth { get; set; } = 512; - [Range(1, 1024)] - public int LargePictureHeight { get; set; } = 512; - } -} \ No newline at end of file + [Range(1, 1024)] + public int LargePictureHeight { get; set; } = 512; +} diff --git a/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs b/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs index 90ac7aa..6a275bb 100644 --- a/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs @@ -1,18 +1,17 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Application.Configuration +namespace Musify.Application.Configuration; + + +public sealed class PlaybackConfiguration { + public const string SectionName = "Playback"; - public sealed class PlaybackConfiguration - { - public const string SectionName = "Playback"; + public bool AllowAnonymousListening { get; set; } - public bool AllowAnonymousListening { get; set; } + [Range(0, 3600)] + public int AnonymousFragmentSeconds { get; set; } - [Range(0, 3600)] - public int AnonymousFragmentSeconds { get; set; } - - [Range(1, int.MaxValue)] - public int EstimatedAudioBytesPerSecond { get; set; } = 16_000; - } + [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 90a92b9..c1083a3 100644 --- a/backend/Core/Musify.Application/Configuration/Responses/PlaybackPublicConfigResponse.cs +++ b/backend/Core/Musify.Application/Configuration/Responses/PlaybackPublicConfigResponse.cs @@ -1,5 +1,4 @@ -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 edba302..03a3dcb 100644 --- a/backend/Core/Musify.Application/Configuration/StreamGatewayConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/StreamGatewayConfiguration.cs @@ -1,16 +1,15 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Application.Configuration +namespace Musify.Application.Configuration; + +public class StreamGatewayConfiguration { - public class StreamGatewayConfiguration - { - public const string SectionName = "StreamGateway"; + public const 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 1304e29..1e74b48 100644 --- a/backend/Core/Musify.Application/Configuration/TrackConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/TrackConfiguration.cs @@ -1,116 +1,115 @@ using System.ComponentModel.DataAnnotations; using Musify.Application.Shared; -namespace Musify.Application.Configuration +namespace Musify.Application.Configuration; + +public class TrackConfiguration { - public class TrackConfiguration - { - public const string SectionName = "Track"; + public const string SectionName = "Track"; - [Required] - public TrackRoutes Routes { get; set; } = new TrackRoutes(); + [Required] + public TrackRoutes Routes { get; set; } = new TrackRoutes(); - [Required] - public TrackPicturesSizes PicturesSizes { get; set; } = new TrackPicturesSizes(); - } + [Required] + public TrackPicturesSizes PicturesSizes { get; set; } = new TrackPicturesSizes(); +} - public class TrackRoutes - { - [Required] - public string UploadsFolder { get; set; } = "uploads"; +public class TrackRoutes +{ + [Required] + public string UploadsFolder { get; set; } = "uploads"; - [Required] - public string ParentFolder { get; set; } = "Tracks"; + [Required] + public string ParentFolder { get; set; } = "Tracks"; - [Required] - public string OriginalPicturesFolder { get; set; } = "OriginalPictures"; + [Required] + public string OriginalPicturesFolder { get; set; } = "OriginalPictures"; - [Required] - public string SmallPicturesFolder { get; set; } = "SmallPictures"; + [Required] + public string SmallPicturesFolder { get; set; } = "SmallPictures"; - [Required] - public string MediumPicturesFolder { get; set; } = "MediumPictures"; + [Required] + public string MediumPicturesFolder { get; set; } = "MediumPictures"; - [Required] - public string LargePicturesFolder { get; set; } = "LargePictures"; + [Required] + public string LargePicturesFolder { get; set; } = "LargePictures"; - [Required] - public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp"; + [Required] + public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp"; - [Required] - public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp"; + [Required] + public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp"; - [Required] - public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp"; + [Required] + public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp"; - [Required] - public string PresetLargePicture { get; set; } = "PresetLargePicture.webp"; + [Required] + public string PresetLargePicture { get; set; } = "PresetLargePicture.webp"; - [Required] - public string OriginalAudiosFolder { get; set; } = "OriginalAudios"; + [Required] + public string OriginalAudiosFolder { get; set; } = "OriginalAudios"; - [Required] - public string ProcessedAudioFolder { get; set; } = "ProcessedAudios"; + [Required] + public string ProcessedAudioFolder { get; set; } = "ProcessedAudios"; - public string SmallPicturesPath => StorageKey.Combine(ParentFolder, SmallPicturesFolder); + public string SmallPicturesPath => StorageKey.Combine(ParentFolder, SmallPicturesFolder); - public string MediumPicturesPath => StorageKey.Combine(ParentFolder, MediumPicturesFolder); + public string MediumPicturesPath => StorageKey.Combine(ParentFolder, MediumPicturesFolder); - public string LargePicturesPath => StorageKey.Combine(ParentFolder, LargePicturesFolder); + public string LargePicturesPath => StorageKey.Combine(ParentFolder, LargePicturesFolder); - public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture); + public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture); - public string PresetMediumPicturePath => StorageKey.Combine(ParentFolder, PresetMediumPicture); + public string PresetMediumPicturePath => StorageKey.Combine(ParentFolder, PresetMediumPicture); - public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture); + public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture); - public string OriginalPicturesPath => StorageKey.Combine(ParentFolder, OriginalPicturesFolder); + public string OriginalPicturesPath => StorageKey.Combine(ParentFolder, OriginalPicturesFolder); - public string OriginalAudiosPath => StorageKey.Combine(ParentFolder, OriginalAudiosFolder); + public string OriginalAudiosPath => StorageKey.Combine(ParentFolder, OriginalAudiosFolder); - public string ProcessedAudiosPath => StorageKey.Combine(ParentFolder, ProcessedAudioFolder); + public string ProcessedAudiosPath => StorageKey.Combine(ParentFolder, ProcessedAudioFolder); - public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(OriginalPicturesPath, pictureName); + public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(OriginalPicturesPath, pictureName); - public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalPicturesPath, pictureName); + public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalPicturesPath, pictureName); - public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(SmallPicturesPath, pictureName); + public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(SmallPicturesPath, pictureName); - public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(MediumPicturesPath, pictureName); + public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(MediumPicturesPath, pictureName); - public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(LargePicturesPath, pictureName); + public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(LargePicturesPath, pictureName); - public string BuildOriginalAudioPath(string audioName) => StorageKey.Combine(OriginalAudiosPath, audioName); + public string BuildOriginalAudioPath(string audioName) => StorageKey.Combine(OriginalAudiosPath, audioName); - public string BuildOriginalAudioPath(long userId, string audioName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalAudiosPath, audioName); + public string BuildOriginalAudioPath(long userId, string audioName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalAudiosPath, audioName); - public string BuildProcessedAudioPath(string audioName) => StorageKey.Combine(ProcessedAudiosPath, audioName); + public string BuildProcessedAudioPath(string audioName) => StorageKey.Combine(ProcessedAudiosPath, audioName); - public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) => - StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName); + public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) => + StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName); - public string BuildTempAudioPath(string tempRootPrefix, long userId, string objectName) => - StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName); - } + public string BuildTempAudioPath(string tempRootPrefix, long userId, string objectName) => + StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName); +} - public class TrackPicturesSizes - { - [Range(1, 1024)] - public int SmallPictureWidth { get; set; } = 128; +public class TrackPicturesSizes +{ + [Range(1, 1024)] + public int SmallPictureWidth { get; set; } = 128; - [Range(1, 1024)] - public int SmallPictureHeight { get; set; } = 128; + [Range(1, 1024)] + public int SmallPictureHeight { get; set; } = 128; - [Range(1, 1024)] - public int MediumPictureWidth { get; set; } = 256; + [Range(1, 1024)] + public int MediumPictureWidth { get; set; } = 256; - [Range(1, 1024)] - public int MediumPictureHeight { get; set; } = 256; + [Range(1, 1024)] + public int MediumPictureHeight { get; set; } = 256; - [Range(1, 1024)] - public int LargePictureWidth { get; set; } = 512; + [Range(1, 1024)] + public int LargePictureWidth { get; set; } = 512; - [Range(1, 1024)] - public int LargePictureHeight { get; set; } = 512; - } -} \ No newline at end of file + [Range(1, 1024)] + public int LargePictureHeight { get; set; } = 512; +} diff --git a/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs b/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs index b18d32d..15463ea 100644 --- a/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs +++ b/backend/Core/Musify.Application/Configuration/UploadIntentConfiguration.cs @@ -1,42 +1,41 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Application.Configuration +namespace Musify.Application.Configuration; + +public class UploadIntentConfiguration { - public class UploadIntentConfiguration - { - public const string SectionName = "UploadIntent"; + public const 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 TemporalRootPrefix { get; set; } = "temporal"; + [Required, MinLength(1)] + public string TemporalRootPrefix { get; set; } = "temporal"; - [Range(1, 30)] - public int TemporalUploadsRetentionDays { get; set; } = 3; + [Range(1, 30)] + public int TemporalUploadsRetentionDays { get; set; } = 3; - [Range(60, 86400)] - public int TemporalCleanUpJobIntervalSeconds { get; set; } = 3600; - } + [Range(60, 86400)] + public int TemporalCleanUpJobIntervalSeconds { get; set; } = 3600; } diff --git a/backend/Core/Musify.Application/Contracts/AudioTranscodeResult.cs b/backend/Core/Musify.Application/Contracts/AudioTranscodeResult.cs index 43902f1..85e45e4 100644 --- a/backend/Core/Musify.Application/Contracts/AudioTranscodeResult.cs +++ b/backend/Core/Musify.Application/Contracts/AudioTranscodeResult.cs @@ -1,4 +1,3 @@ -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 6f6c885..3f0558a 100644 --- a/backend/Core/Musify.Application/Contracts/IAudioTranscoderService.cs +++ b/backend/Core/Musify.Application/Contracts/IAudioTranscoderService.cs @@ -1,9 +1,8 @@ -namespace Musify.Application.Contracts -{ - public interface IAudioTranscoderService - { - Task TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken); +namespace Musify.Application.Contracts; - Task IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken); - } -} \ No newline at end of file +public interface IAudioTranscoderService +{ + public Task TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken); + + public Task IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken); +} diff --git a/backend/Core/Musify.Application/Contracts/IDatabase.cs b/backend/Core/Musify.Application/Contracts/IDatabase.cs index df8ebe6..ea3c605 100644 --- a/backend/Core/Musify.Application/Contracts/IDatabase.cs +++ b/backend/Core/Musify.Application/Contracts/IDatabase.cs @@ -1,42 +1,41 @@ using Microsoft.EntityFrameworkCore; using Musify.Domain.Entities; -namespace Musify.Application.Contracts +namespace Musify.Application.Contracts; + +public interface IDatabase { - public interface IDatabase - { - DbSet Users { get; } + public DbSet Users { get; } - DbSet PlayLists { get; } + public DbSet PlayLists { get; } - DbSet Albums { get; } + public DbSet Albums { get; } - DbSet AlbumHasTracks { get; } + public DbSet AlbumHasTracks { get; } - DbSet Tracks { get; } + public DbSet Tracks { get; } - DbSet UserHasTracks { get; } + public DbSet UserHasTracks { get; } - DbSet PlayListHasTracks { get; } + public DbSet PlayListHasTracks { get; } - DbSet Mixes { get; } + public DbSet Mixes { get; } - DbSet MixItems { get; } + public DbSet MixItems { get; } - DbSet Uploads { get; } + public DbSet Uploads { get; } - DbSet UploadIntents { get; } + public DbSet UploadIntents { get; } - DbSet ListeningHistories { get; } + public DbSet ListeningHistories { get; } - DbSet TrackLikes { get; } + public DbSet TrackLikes { get; } - DbSet TrackTags { get; } + public DbSet TrackTags { get; } - DbSet UserFollows { get; } + public DbSet UserFollows { get; } - Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); + public Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); - Task SaveChangesAsync(CancellationToken cancellationToken); - } -} \ No newline at end of file + public Task SaveChangesAsync(CancellationToken cancellationToken); +} diff --git a/backend/Core/Musify.Application/Contracts/IDatabaseTransaction.cs b/backend/Core/Musify.Application/Contracts/IDatabaseTransaction.cs index 42e6185..5e71827 100644 --- a/backend/Core/Musify.Application/Contracts/IDatabaseTransaction.cs +++ b/backend/Core/Musify.Application/Contracts/IDatabaseTransaction.cs @@ -1,7 +1,6 @@ -namespace Musify.Application.Contracts +namespace Musify.Application.Contracts; + +public interface IDatabaseTransaction : IAsyncDisposable { - public interface IDatabaseTransaction : IAsyncDisposable - { - Task CommitAsync(CancellationToken cancellationToken = default); - } + 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 1f5acb9..bd9182a 100644 --- a/backend/Core/Musify.Application/Contracts/IEventBus.cs +++ b/backend/Core/Musify.Application/Contracts/IEventBus.cs @@ -1,7 +1,6 @@ -namespace Musify.Application.Contracts +namespace Musify.Application.Contracts; + +public interface IEventBus { - public interface IEventBus - { - Task PublishAsync(T message, CancellationToken cancellationToken) where T : class; - } -} \ No newline at end of file + 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 8c8ccd2..3e74bf4 100644 --- a/backend/Core/Musify.Application/Contracts/IPictureService.cs +++ b/backend/Core/Musify.Application/Contracts/IPictureService.cs @@ -1,7 +1,6 @@ -namespace Musify.Application.Contracts +namespace Musify.Application.Contracts; + +public interface IPictureService { - public interface IPictureService - { - Task ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken); - } -} \ No newline at end of file + 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 e5f3c24..4b419be 100644 --- a/backend/Core/Musify.Application/Contracts/IStorageService.cs +++ b/backend/Core/Musify.Application/Contracts/IStorageService.cs @@ -1,28 +1,27 @@ -namespace Musify.Application.Contracts +namespace Musify.Application.Contracts; + +public interface IStorageService { - public interface IStorageService - { - Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken); + public Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken); - Task HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken); + public Task HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken); - Task GetUrlAsync(string bucket, string key, TimeSpan ExpirationTime, CancellationToken cancellationToken); + public Task GetUrlAsync(string bucket, string key, TimeSpan ExpirationTime, CancellationToken cancellationToken); - Task GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, CancellationToken cancellationToken, bool preventOverwrite = true); + public Task GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, CancellationToken cancellationToken, bool preventOverwrite = true); - Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken); + public Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken); - 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); - Task> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken); + public Task> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken); - Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken); + public Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken); - 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); - Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken); - - IAsyncEnumerable ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken); - } -} \ No newline at end of file + public Task RemoveFolderAsync(string bucket, string folderKey, 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 26925ac..2668555 100644 --- a/backend/Core/Musify.Application/Contracts/IStreamTicketService.cs +++ b/backend/Core/Musify.Application/Contracts/IStreamTicketService.cs @@ -1,10 +1,8 @@ -namespace Musify.Application.Contracts +namespace Musify.Application.Contracts; + +public interface IStreamTicketService { - public interface IStreamTicketService - { - - StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null); - } - - public record StreamTicket(string Token, int ExpiresInSeconds); + public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null); } + +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 6406463..0211261 100644 --- a/backend/Core/Musify.Application/Contracts/ObjectMetaData.cs +++ b/backend/Core/Musify.Application/Contracts/ObjectMetaData.cs @@ -1,4 +1,3 @@ -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 2ec4d45..9572137 100644 --- a/backend/Core/Musify.Application/Contracts/StorageObject.cs +++ b/backend/Core/Musify.Application/Contracts/StorageObject.cs @@ -1,4 +1,3 @@ -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 73db1d3..99b2cb6 100644 --- a/backend/Core/Musify.Application/Events/AlbumPictureProcessed.cs +++ b/backend/Core/Musify.Application/Events/AlbumPictureProcessed.cs @@ -1,4 +1,3 @@ -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 24f4cc5..f659536 100644 --- a/backend/Core/Musify.Application/Events/AlbumPictureProcessingFailed.cs +++ b/backend/Core/Musify.Application/Events/AlbumPictureProcessingFailed.cs @@ -1,4 +1,3 @@ -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 5ee8895..4640faf 100644 --- a/backend/Core/Musify.Application/Events/AlbumProcessingFailed.cs +++ b/backend/Core/Musify.Application/Events/AlbumProcessingFailed.cs @@ -1,7 +1,6 @@ -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 a6bbe38..18290e1 100644 --- a/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs +++ b/backend/Core/Musify.Application/Events/CreateAlbumResourcesEvent.cs @@ -1,14 +1,13 @@ using Musify.Application.Shared; -namespace Musify.Application.Events -{ - public record CreateAlbumResourcesEvent( - Guid AlbumId, - Guid PictureIntentId, - string Bucket, - string PictureSourceKey, - string PictureDestinationKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); -} +namespace Musify.Application.Events; + +public record CreateAlbumResourcesEvent( + Guid AlbumId, + Guid PictureIntentId, + string Bucket, + string PictureSourceKey, + string PictureDestinationKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); diff --git a/backend/Core/Musify.Application/Events/CreatePlayListResourcesEvent.cs b/backend/Core/Musify.Application/Events/CreatePlayListResourcesEvent.cs index 80266cb..b26409c 100644 --- a/backend/Core/Musify.Application/Events/CreatePlayListResourcesEvent.cs +++ b/backend/Core/Musify.Application/Events/CreatePlayListResourcesEvent.cs @@ -1,14 +1,13 @@ using Musify.Application.Shared; -namespace Musify.Application.Events -{ - public record CreatePlayListResourcesEvent( - Guid PlayListId, - Guid PictureIntentId, - string Bucket, - string PictureSourceKey, - string PictureDestinationKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); -} +namespace Musify.Application.Events; + +public record CreatePlayListResourcesEvent( + Guid PlayListId, + Guid PictureIntentId, + string Bucket, + string PictureSourceKey, + string PictureDestinationKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); diff --git a/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs b/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs index e7f08b5..da92b47 100644 --- a/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs +++ b/backend/Core/Musify.Application/Events/CreateTrackResourcesEvent.cs @@ -1,18 +1,17 @@ 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, - ImageSize Small, - ImageSize Medium, - ImageSize Large); -} +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, + ImageSize Small, + ImageSize Medium, + ImageSize Large); diff --git a/backend/Core/Musify.Application/Events/DeletePlayListEvent.cs b/backend/Core/Musify.Application/Events/DeletePlayListEvent.cs index 06a7a2c..40d589b 100644 --- a/backend/Core/Musify.Application/Events/DeletePlayListEvent.cs +++ b/backend/Core/Musify.Application/Events/DeletePlayListEvent.cs @@ -1,6 +1,5 @@ -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 8fb6923..7c78c6f 100644 --- a/backend/Core/Musify.Application/Events/DeleteTrackEvent.cs +++ b/backend/Core/Musify.Application/Events/DeleteTrackEvent.cs @@ -1,6 +1,5 @@ -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 74f03f2..ddb68e7 100644 --- a/backend/Core/Musify.Application/Events/PlayListPictureProcessed.cs +++ b/backend/Core/Musify.Application/Events/PlayListPictureProcessed.cs @@ -1,4 +1,3 @@ -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 661932c..01e0b9c 100644 --- a/backend/Core/Musify.Application/Events/PlayListPictureProcessingFailed.cs +++ b/backend/Core/Musify.Application/Events/PlayListPictureProcessingFailed.cs @@ -1,4 +1,3 @@ -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 50a5054..6c97a28 100644 --- a/backend/Core/Musify.Application/Events/PlayListProcessingFailed.cs +++ b/backend/Core/Musify.Application/Events/PlayListProcessingFailed.cs @@ -1,7 +1,6 @@ -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 9648bf2..3bc35a3 100644 --- a/backend/Core/Musify.Application/Events/TrackAudioProcessed.cs +++ b/backend/Core/Musify.Application/Events/TrackAudioProcessed.cs @@ -1,4 +1,3 @@ -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 c531163..2757cdd 100644 --- a/backend/Core/Musify.Application/Events/TrackAudioProcessingFailed.cs +++ b/backend/Core/Musify.Application/Events/TrackAudioProcessingFailed.cs @@ -1,4 +1,3 @@ -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 e42b30f..0c39420 100644 --- a/backend/Core/Musify.Application/Events/TrackPictureProcessed.cs +++ b/backend/Core/Musify.Application/Events/TrackPictureProcessed.cs @@ -1,4 +1,3 @@ -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 df08dec..593253a 100644 --- a/backend/Core/Musify.Application/Events/TrackPictureProcessingFailed.cs +++ b/backend/Core/Musify.Application/Events/TrackPictureProcessingFailed.cs @@ -1,4 +1,3 @@ -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 5234fe6..c7fb5c5 100644 --- a/backend/Core/Musify.Application/Events/TrackProcessingFailed.cs +++ b/backend/Core/Musify.Application/Events/TrackProcessingFailed.cs @@ -1,8 +1,7 @@ -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 338dc6c..35324bb 100644 --- a/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdateAlbumPictureEvent.cs @@ -1,12 +1,11 @@ using Musify.Application.Shared; -namespace Musify.Application.Events -{ - public record UpdateAlbumPictureEvent( - Guid AlbumId, - string Bucket, - string SourceKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); -} +namespace Musify.Application.Events; + +public record UpdateAlbumPictureEvent( + Guid AlbumId, + string Bucket, + string SourceKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); diff --git a/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs b/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs index a6de018..72a7c52 100644 --- a/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdateAlbumPictureSourceEvent.cs @@ -1,14 +1,13 @@ using Musify.Application.Shared; -namespace Musify.Application.Events -{ - public record UpdateAlbumPictureSourceEvent( - Guid AlbumId, - Guid PictureIntentId, - string Bucket, - string PictureSourceKey, - string PictureDestinationKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); -} +namespace Musify.Application.Events; + +public record UpdateAlbumPictureSourceEvent( + Guid AlbumId, + Guid PictureIntentId, + string Bucket, + string PictureSourceKey, + string PictureDestinationKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); diff --git a/backend/Core/Musify.Application/Events/UpdatePlayListPictureEvent.cs b/backend/Core/Musify.Application/Events/UpdatePlayListPictureEvent.cs index d617c75..93eb1fd 100644 --- a/backend/Core/Musify.Application/Events/UpdatePlayListPictureEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdatePlayListPictureEvent.cs @@ -1,12 +1,11 @@ using Musify.Application.Shared; -namespace Musify.Application.Events -{ - public record UpdatePlayListPictureEvent( - Guid PlayListId, - string Bucket, - string SourceKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); -} \ No newline at end of file +namespace Musify.Application.Events; + +public record UpdatePlayListPictureEvent( + Guid PlayListId, + string Bucket, + string SourceKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); diff --git a/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs b/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs index 24c7101..b3c0f61 100644 --- a/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdatePlayListPictureSourceEvent.cs @@ -1,14 +1,13 @@ using Musify.Application.Shared; -namespace Musify.Application.Events -{ - public record UpdatePlayListPictureSourceEvent( - Guid PlayListId, - Guid PictureIntentId, - string Bucket, - string PictureSourceKey, - string PictureDestinationKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); -} +namespace Musify.Application.Events; + +public record UpdatePlayListPictureSourceEvent( + Guid PlayListId, + Guid PictureIntentId, + string Bucket, + string PictureSourceKey, + string PictureDestinationKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); diff --git a/backend/Core/Musify.Application/Events/UpdateTrackAudioEvent.cs b/backend/Core/Musify.Application/Events/UpdateTrackAudioEvent.cs index 30c7ede..a3b6b60 100644 --- a/backend/Core/Musify.Application/Events/UpdateTrackAudioEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdateTrackAudioEvent.cs @@ -1,9 +1,8 @@ -namespace Musify.Application.Events -{ - public record UpdateTrackAudioEvent( - Guid TrackId, - string SourceBucket, - string SourceKey, - string DestinationBucket, - string DestinationFolderKey); -} \ No newline at end of file +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 8973e64..bab9787 100644 --- a/backend/Core/Musify.Application/Events/UpdateTrackPictureEvent.cs +++ b/backend/Core/Musify.Application/Events/UpdateTrackPictureEvent.cs @@ -1,12 +1,11 @@ using Musify.Application.Shared; -namespace Musify.Application.Events -{ - public record UpdateTrackPictureEvent( - Guid TrackId, - string Bucket, - string SourceKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); -} \ No newline at end of file +namespace Musify.Application.Events; + +public record UpdateTrackPictureEvent( + Guid TrackId, + string Bucket, + string SourceKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); diff --git a/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs b/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs index 32c2563..733ba22 100644 --- a/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs +++ b/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs @@ -1,26 +1,25 @@ using Mediator; using Musify.Domain.ValueObjects; -namespace Musify.Application.Genres +namespace Musify.Application.Genres; + +public record GetAvailableGenresQuery : IQuery>; + +public record AvailableGenreResponse(Genre Genre, IReadOnlyList IncompatibleWith); + +public class GetAvailableGenresQueryHandler + : IQueryHandler> { - public record GetAvailableGenresQuery : IQuery>; - - public record AvailableGenreResponse(Genre Genre, IReadOnlyList IncompatibleWith); - - public class GetAvailableGenresQueryHandler - : IQueryHandler> + public ValueTask> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken) { - public ValueTask> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken) - { - var all = Enum.GetValues(); + 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 1dece8f..82fbf97 100644 --- a/backend/Core/Musify.Application/Genres/GetGenres.cs +++ b/backend/Core/Musify.Application/Genres/GetGenres.cs @@ -3,29 +3,28 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Domain.ValueObjects; -namespace Musify.Application.Genres +namespace Musify.Application.Genres; + +public record GetGenresQuery : IQuery>; + +public record GenreResponse(Genre Genre, int TrackCount); + +public class GetGenresQueryHandler(IDatabase database) + : IQueryHandler> { - public record GetGenresQuery : IQuery>; - - public record GenreResponse(Genre Genre, int TrackCount); - - public class GetGenresQueryHandler(IDatabase database) - : IQueryHandler> + public async ValueTask> Handle(GetGenresQuery request, CancellationToken cancellationToken) { - public async ValueTask> Handle(GetGenresQuery request, CancellationToken cancellationToken) - { - var rows = await database.TrackTags - .AsNoTracking() - .Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId)) - .GroupBy(tag => tag.Tag) - .Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() }) - .ToListAsync(cancellationToken); + var rows = await database.TrackTags + .AsNoTracking() + .Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId)) + .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(); - } + 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/Likes/GetLikedTracks.cs b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs index e50d7c1..d36f67c 100644 --- a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs +++ b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs @@ -7,37 +7,36 @@ using Musify.Application.Tracks.Responses; using X.PagedList; using X.PagedList.EF; -namespace Musify.Application.Likes +namespace Musify.Application.Likes; + +public record GetLikedTracksQuery( + long UserId, + int PageNumber, + int PageSize) + : IQuery>>; + +public class GetLikedTracksQueryHandler(IDatabase database) + : IQueryHandler>> { - public record GetLikedTracksQuery( - long UserId, - int PageNumber, - int PageSize) - : IQuery>>; - - public class GetLikedTracksQueryHandler(IDatabase database) - : IQueryHandler>> + public async ValueTask>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken) { - public async ValueTask>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken) - { - var likesQuery = database.TrackLikes - .AsNoTracking() - .Include(like => like.Track.Owner) - .Include(like => like.Track.Tags) - .Where(like => like.UserId == request.UserId); + var likesQuery = database.TrackLikes + .AsNoTracking() + .Include(like => like.Track.Owner) + .Include(like => like.Track.Tags) + .Where(like => like.UserId == request.UserId); - var totalCount = await likesQuery.CountAsync(cancellationToken); + var totalCount = await likesQuery.CountAsync(cancellationToken); - var pagedEntities = await likesQuery - .OrderByDescending(like => like.CreatedAt) - .Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count(l => l.IsCounted) }) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + var pagedEntities = await likesQuery + .OrderByDescending(like => like.CreatedAt) + .Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count(l => l.IsCounted) }) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - var pagedTracks = new StaticPagedList( - pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)), - pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); + var pagedTracks = new StaticPagedList( + pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)), + pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); - return PaginatedResponse.FromPagedList(pagedTracks); - } + return PaginatedResponse.FromPagedList(pagedTracks); } } diff --git a/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs index 7fe93a1..d87d641 100644 --- a/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs +++ b/backend/Core/Musify.Application/Likes/ToggleTrackLike.cs @@ -5,52 +5,51 @@ using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Domain.Entities; -namespace Musify.Application.Likes +namespace Musify.Application.Likes; + +public record ToggleTrackLikeCommand(long UserId, Guid TrackId) + : ICommand>; + +public class ToggleTrackLikeCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> { - public record ToggleTrackLikeCommand(long UserId, Guid TrackId) - : ICommand>; - - public class ToggleTrackLikeCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> + public async ValueTask> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken) { - public async ValueTask> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken) + var trackExists = await database.Tracks + .AsNoTracking() + .AnyAsync(t => t.Id == request.TrackId, cancellationToken); + if (!trackExists) { - var trackExists = await database.Tracks - .AsNoTracking() - .AnyAsync(t => t.Id == request.TrackId, cancellationToken); - if (!trackExists) - { - logger.LogInformation("Track {TrackId} not found", request.TrackId); - return Error.NotFound(description: $"Track {request.TrackId} not found"); - } - - var existingLike = await database.TrackLikes - .SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken); - - bool nowLiked; - if (existingLike != null) - { - database.TrackLikes.Remove(existingLike); - nowLiked = false; - } - else - { - await database.TrackLikes.AddAsync(new TrackLike - { - UserId = request.UserId, - TrackId = request.TrackId - }, cancellationToken); - nowLiked = true; - } - - await database.SaveChangesAsync(cancellationToken); - - logger.LogInformation("User {UserId} {Action} track {TrackId}", - request.UserId, nowLiked ? "liked" : "unliked", request.TrackId); - - return nowLiked; + logger.LogInformation("Track {TrackId} not found", request.TrackId); + return Error.NotFound(description: $"Track {request.TrackId} not found"); } + + var existingLike = await database.TrackLikes + .SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken); + + bool nowLiked; + if (existingLike != null) + { + database.TrackLikes.Remove(existingLike); + nowLiked = false; + } + else + { + await database.TrackLikes.AddAsync(new TrackLike + { + UserId = request.UserId, + TrackId = request.TrackId + }, cancellationToken); + nowLiked = true; + } + + await database.SaveChangesAsync(cancellationToken); + + 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 84df25f..bc45f5e 100644 --- a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs +++ b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs @@ -7,146 +7,145 @@ using Musify.Application.Contracts; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Mixes +namespace Musify.Application.Mixes; + +public record GenerateMixesForUserCommand(long UserId) + : ICommand>; + +public class GenerateMixesForUserCommandHandler( + IDatabase database, + MixConfiguration mixConfiguration, + ILogger logger) + : ICommandHandler> { - public record GenerateMixesForUserCommand(long UserId) - : ICommand>; + private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList TrackIds); - public class GenerateMixesForUserCommandHandler( - IDatabase database, - MixConfiguration mixConfiguration, - ILogger logger) - : ICommandHandler> + public async ValueTask> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken) { - private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList TrackIds); + var userId = request.UserId; - public async ValueTask> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken) + var libraryTrackIds = await database.UserHasTracks + .AsNoTracking() + .Where(userTrack => userTrack.UserId == userId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) + .Select(userTrack => userTrack.TrackId) + .ToListAsync(cancellationToken); + + if (libraryTrackIds.Count == 0) { - var userId = request.UserId; - - var libraryTrackIds = await database.UserHasTracks - .AsNoTracking() - .Where(userTrack => userTrack.UserId == userId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) - .Select(userTrack => userTrack.TrackId) - .ToListAsync(cancellationToken); - - if (libraryTrackIds.Count == 0) - { - logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId); - return new Success(); - } - - 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( - "Descubrimiento", - "Canciones de tu biblioteca que todavía no has escuchado.", - discovery)); - } - - var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds))); - if (daily.Count > 0) - { - drafts.Add(new MixDraft( - "Tu mezcla diaria", - "Lo que más escuchas, con alguna sorpresa de tu biblioteca.", - daily)); - } - - if (drafts.Count == 0) - { - logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId); - return new Success(); - } - - await ReplaceMixesAsync(userId, drafts, cancellationToken); - - try - { - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to generate mixes for user {UserId}", userId); - return Error.Failure(description: $"Failed to generate mixes for user {userId}"); - } - - logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId); + logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId); return new Success(); } - private List Take(IEnumerable trackIds) => - [.. trackIds.Take(mixConfiguration.ItemsPerMix)]; + var historyTrackIds = await database.ListeningHistories + .AsNoTracking() + .Where(history => history.UserId == userId && history.IsCounted) + .Select(history => history.TrackId) + .Distinct() + .ToListAsync(cancellationToken); - private static List Shuffle(IEnumerable trackIds) => - [.. trackIds.OrderBy(_ => Guid.NewGuid())]; + var historySet = new HashSet(historyTrackIds); + var unheardTrackIds = libraryTrackIds.Where(id => !historySet.Contains(id)).ToList(); - private static List Interleave(IReadOnlyList first, IReadOnlyList second) + var drafts = new List(); + + var discovery = Take(Shuffle(unheardTrackIds)); + if (discovery.Count > 0) { - 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; + drafts.Add(new MixDraft( + "Descubrimiento", + "Canciones de tu biblioteca que todavía no has escuchado.", + discovery)); } - private async Task ReplaceMixesAsync(long userId, IReadOnlyList drafts, CancellationToken cancellationToken) + var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds))); + if (daily.Count > 0) { - var previousMixes = await database.Mixes - .Where(mix => mix.UserId == userId) + drafts.Add(new MixDraft( + "Tu mezcla diaria", + "Lo que más escuchas, con alguna sorpresa de tu biblioteca.", + daily)); + } + + if (drafts.Count == 0) + { + logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId); + return new Success(); + } + + await ReplaceMixesAsync(userId, drafts, cancellationToken); + + try + { + await database.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to generate mixes for user {UserId}", userId); + return Error.Failure(description: $"Failed to generate mixes for user {userId}"); + } + + logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId); + return new Success(); + } + + private List Take(IEnumerable trackIds) => + [.. trackIds.Take(mixConfiguration.ItemsPerMix)]; + + private static List Shuffle(IEnumerable trackIds) => + [.. trackIds.OrderBy(_ => Guid.NewGuid())]; + + private static List Interleave(IReadOnlyList first, IReadOnlyList 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) + { + var previousIds = previousMixes.Select(mix => mix.Id).ToList(); + var previousItems = await database.MixItems + .Where(item => previousIds.Contains(item.MixId)) .ToListAsync(cancellationToken); - if (previousMixes.Count > 0) + database.MixItems.RemoveRange(previousItems); + database.Mixes.RemoveRange(previousMixes); + } + + for (var position = 0; position < drafts.Count; position++) + { + var draft = drafts[position]; + var mix = new Mix { - var previousIds = previousMixes.Select(mix => mix.Id).ToList(); - var previousItems = await database.MixItems - .Where(item => previousIds.Contains(item.MixId)) - .ToListAsync(cancellationToken); + UserId = userId, + Title = draft.Title, + Subtitle = draft.Subtitle, + Position = position + }; - database.MixItems.RemoveRange(previousItems); - database.Mixes.RemoveRange(previousMixes); - } + await database.Mixes.AddAsync(mix, cancellationToken); - for (var position = 0; position < drafts.Count; position++) + for (var itemPosition = 0; itemPosition < draft.TrackIds.Count; itemPosition++) { - var draft = drafts[position]; - var mix = new Mix + await database.MixItems.AddAsync(new MixItem { - UserId = userId, - Title = draft.Title, - Subtitle = draft.Subtitle, - 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); - } + 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 c909ad1..75752ac 100644 --- a/backend/Core/Musify.Application/Mixes/GetMixById.cs +++ b/backend/Core/Musify.Application/Mixes/GetMixById.cs @@ -4,28 +4,27 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Mixes.Responses; -namespace Musify.Application.Mixes +namespace Musify.Application.Mixes; + +public record GetMixByIdQuery(long UserId, Guid MixId) + : IQuery>; + +public class GetMixByIdQueryHandler(IDatabase database) + : IQueryHandler> { - public record GetMixByIdQuery(long UserId, Guid MixId) - : IQuery>; - - public class GetMixByIdQueryHandler(IDatabase database) - : IQueryHandler> + public async ValueTask> Handle(GetMixByIdQuery request, CancellationToken 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); + 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 Error.NotFound(description: $"Mix {request.MixId} was not found"); + if (mix == null) + return Error.NotFound(description: $"Mix {request.MixId} was not found"); - 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.Title, mix.Subtitle, items.Count, items); - } + return new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, items.Count, items); } } diff --git a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs index 570aa6e..d988bb0 100644 --- a/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs +++ b/backend/Core/Musify.Application/Mixes/GetMixesByUserId.cs @@ -4,34 +4,33 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Mixes.Responses; -namespace Musify.Application.Mixes +namespace Musify.Application.Mixes; + +public record GetMixesByUserIdQuery(long UserId) + : IQuery>>; + +public class GetMixesByUserIdQueryHandler(IDatabase database) + : IQueryHandler>> { - public record GetMixesByUserIdQuery(long UserId) - : IQuery>>; - - public class GetMixesByUserIdQueryHandler(IDatabase database) - : IQueryHandler>> + public async ValueTask>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken) { - public async ValueTask>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken) + var mixes = await database.Mixes + .AsNoTracking() + .Where(mix => mix.UserId == request.UserId) + .Include(mix => mix.Items) + .OrderBy(mix => mix.Position) + .ToListAsync(cancellationToken); + + var responses = new List(mixes.Count); + foreach (var mix in mixes) { - var mixes = await database.Mixes - .AsNoTracking() - .Where(mix => mix.UserId == request.UserId) - .Include(mix => mix.Items) - .OrderBy(mix => mix.Position) - .ToListAsync(cancellationToken); + var orderedItems = mix.Items.OrderBy(item => item.Position).ToList(); + var coverItems = await MixItemMapper.ToResponsesAsync( + database, orderedItems.Take(MixApplicationResponse.CoverItemCount).ToList(), cancellationToken); - var responses = new List(mixes.Count); - foreach (var mix in mixes) - { - var orderedItems = mix.Items.OrderBy(item => item.Position).ToList(); - var coverItems = await MixItemMapper.ToResponsesAsync( - database, orderedItems.Take(MixApplicationResponse.CoverItemCount).ToList(), cancellationToken); - - responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, coverItems)); - } - - return responses; + responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, coverItems)); } + + return responses; } } diff --git a/backend/Core/Musify.Application/Mixes/MixItemMapper.cs b/backend/Core/Musify.Application/Mixes/MixItemMapper.cs index e6493df..eba4dd3 100644 --- a/backend/Core/Musify.Application/Mixes/MixItemMapper.cs +++ b/backend/Core/Musify.Application/Mixes/MixItemMapper.cs @@ -3,38 +3,37 @@ using Musify.Application.Contracts; using Musify.Application.Mixes.Responses; using Musify.Domain.Entities; -namespace Musify.Application.Mixes +namespace Musify.Application.Mixes; + + +internal static class MixItemMapper { - - internal static class MixItemMapper + public static async Task> ToResponsesAsync( + IDatabase database, + IReadOnlyList orderedItems, + CancellationToken cancellationToken) { - public static async Task> ToResponsesAsync( - IDatabase database, - IReadOnlyList orderedItems, - CancellationToken cancellationToken) - { - var trackIds = orderedItems.Select(item => item.TrackId).Distinct().ToList(); + 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)) - .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)) + .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 6af9494..bb83d13 100644 --- a/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs +++ b/backend/Core/Musify.Application/Mixes/Responses/MixApplicationResponse.cs @@ -1,12 +1,11 @@ -namespace Musify.Application.Mixes.Responses +namespace Musify.Application.Mixes.Responses; + +public record MixApplicationResponse( + Guid Id, + string Title, + string? Subtitle, + int ItemCount, + IReadOnlyList Items) { - public record MixApplicationResponse( - Guid Id, - string Title, - string? Subtitle, - int ItemCount, - IReadOnlyList Items) - { - public const int CoverItemCount = 4; - } + 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 1b75458..12d2192 100644 --- a/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs +++ b/backend/Core/Musify.Application/Mixes/Responses/MixItemApplicationResponse.cs @@ -1,9 +1,8 @@ -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/PlayLists/AddTrackToPlayList.cs b/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs index 737f9be..1c1c641 100644 --- a/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/AddTrackToPlayList.cs @@ -5,71 +5,70 @@ using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Domain.Entities; -namespace Musify.Application.PlayLists +namespace Musify.Application.PlayLists; + +public record AddTrackToPlayListCommand(long UserId, Guid PlayListId, Guid TrackId) + : ICommand>; + +public class AddTrackToPlayListCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> { - public record AddTrackToPlayListCommand(long UserId, Guid PlayListId, Guid TrackId) - : ICommand>; - - public class AddTrackToPlayListCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> + public async ValueTask> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken) { - public async ValueTask> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken) + var playList = await database.PlayLists + .SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); + if (playList == null) { - var playList = await database.PlayLists - .SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); - if (playList == null) - { - logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId); - return Error.NotFound(); - } - - if (playList.UserId != request.UserId) - { - logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); - return Error.Unauthorized(); - } - - var trackExists = await database.Tracks - .AsNoTracking() - .AnyAsync(t => t.Id == request.TrackId, cancellationToken); - if (!trackExists) - { - logger.LogInformation("Track {TrackId} not found", request.TrackId); - return Error.NotFound(description: $"Track {request.TrackId} not found"); - } - - var alreadyAdded = await database.PlayListHasTracks - .AsNoTracking() - .AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken); - if (alreadyAdded) - { - logger.LogInformation("Track {TrackId} already in playlist {PlayListId}", request.TrackId, request.PlayListId); - return Error.Conflict(description: "Track is already in the playlist."); - } - - var playListTracksQuery = database.PlayListHasTracks - .Where(plt => plt.PlayListId == request.PlayListId); - - var hasTracks = await playListTracksQuery.AnyAsync(cancellationToken); - var nextPosition = hasTracks - ? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1 - : 0; - - await database.PlayListHasTracks.AddAsync(new PlayListHasTrack - { - PlayListId = request.PlayListId, - TrackId = request.TrackId, - Position = nextPosition - }, cancellationToken); - - await database.SaveChangesAsync(cancellationToken); - - logger.LogInformation("Added track {TrackId} to playlist {PlayListId} at position {Position}", - request.TrackId, request.PlayListId, nextPosition); - - return new Success(); + logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId); + return Error.NotFound(); } + + if (playList.UserId != request.UserId) + { + logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); + return Error.Unauthorized(); + } + + var trackExists = await database.Tracks + .AsNoTracking() + .AnyAsync(t => t.Id == request.TrackId, cancellationToken); + if (!trackExists) + { + logger.LogInformation("Track {TrackId} not found", request.TrackId); + return Error.NotFound(description: $"Track {request.TrackId} not found"); + } + + var alreadyAdded = await database.PlayListHasTracks + .AsNoTracking() + .AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken); + if (alreadyAdded) + { + logger.LogInformation("Track {TrackId} already in playlist {PlayListId}", request.TrackId, request.PlayListId); + return Error.Conflict(description: "Track is already in the playlist."); + } + + var playListTracksQuery = database.PlayListHasTracks + .Where(plt => plt.PlayListId == request.PlayListId); + + var hasTracks = await playListTracksQuery.AnyAsync(cancellationToken); + var nextPosition = hasTracks + ? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1 + : 0; + + await database.PlayListHasTracks.AddAsync(new PlayListHasTrack + { + PlayListId = request.PlayListId, + TrackId = request.TrackId, + Position = nextPosition + }, cancellationToken); + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Added track {TrackId} to playlist {PlayListId} at position {Position}", + request.TrackId, request.PlayListId, nextPosition); + + return new Success(); } } diff --git a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs index 1568a88..3481922 100644 --- a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs @@ -3,144 +3,143 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Configuration; -using Musify.Application.Shared; +using Musify.Application.Contracts; using Musify.Application.Events; using Musify.Application.PlayLists.Responses; using Musify.Application.Services; +using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -using Musify.Application.Contracts; -namespace Musify.Application.PlayLists +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, + ApplicationStorageConfiguration storageConfiguration, + PlayListConfiguration playListConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) + : ICommandHandler> { - public record CreatePlayListCommand( - long UserId, - string Name, - string? Description, - Guid? PictureIntentId, - PlaylistVisibility Visibility = PlaylistVisibility.Private) - : ICommand>; + private sealed record ResolvedPlayListPictures(PlayListPictures? Pictures, UploadIntent? Intent); - public class CreatePlayListCommandHandler( - IEventBus eventBus, - IDatabase database, - UploadIntentValidator uploadIntentValidator, - ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - PlayListConfiguration playListConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) - : ICommandHandler> + public async ValueTask> Handle(CreatePlayListCommand request, CancellationToken cancellationToken) { - private sealed record ResolvedPlayListPictures(PlayListPictures? Pictures, UploadIntent? Intent); - - public async ValueTask> Handle(CreatePlayListCommand request, CancellationToken cancellationToken) + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(u => u.Id == request.UserId, cancellationToken); + if (!userExists) { - var userExists = await database.Users - .AsNoTracking() - .AnyAsync(u => u.Id == request.UserId, cancellationToken); - if (!userExists) - { - logger.LogWarning("User {UserId} not found", request.UserId); - return Error.NotFound(description: $"User {request.UserId} not found"); - } - - var picturesResult = await ResolvePicturesAsync(request, cancellationToken); - if (picturesResult.IsError) - return picturesResult.Errors; - - var resolvedPictures = picturesResult.Value; - - var playList = new PlayList - { - UserId = request.UserId, - Name = request.Name, - NormalizedName = request.Name.Trim().ToUpperInvariant(), - Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), - Pictures = resolvedPictures.Pictures, - Visibility = request.Visibility - }; - await database.PlayLists.AddAsync(playList, cancellationToken); - - if (resolvedPictures.Intent != null) - { - var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, resolvedPictures.Pictures!.OriginalName); - var publishResult = await PublishCreatePlayListEventAsync(playList.Id, resolvedPictures.Intent, finalPictureKey, cancellationToken); - if (publishResult.IsError) - return publishResult.Errors; - } - - try - { - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to save playlist for user {UserId}", request.UserId); - return Error.Failure(description: $"Failed to create playlist for user {request.UserId}"); - } - - logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId); - return PlayListApplicationResponse.FromEntity(playList); + logger.LogWarning("User {UserId} not found", request.UserId); + return Error.NotFound(description: $"User {request.UserId} not found"); } - private async ValueTask> ResolvePicturesAsync( - CreatePlayListCommand request, CancellationToken cancellationToken) + var picturesResult = await ResolvePicturesAsync(request, cancellationToken); + if (picturesResult.IsError) + return picturesResult.Errors; + + var resolvedPictures = picturesResult.Value; + + var playList = new PlayList { - if (!request.PictureIntentId.HasValue) - { - return new ResolvedPlayListPictures(null, null); - } + UserId = request.UserId, + Name = request.Name, + NormalizedName = request.Name.Trim().ToUpperInvariant(), + Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), + Pictures = resolvedPictures.Pictures, + Visibility = request.Visibility + }; + await database.PlayLists.AddAsync(playList, cancellationToken); - var validation = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, request.PictureIntentId.Value, request.UserId, cancellationToken); - - if (validation.IsError) - return validation.Errors; - - return new ResolvedPlayListPictures(new PlayListPictures - { - OriginalName = validation.Value.ObjectName, - SmallName = validation.Value.ObjectName, - MediumName = validation.Value.ObjectName, - LargeName = validation.Value.ObjectName - }, validation.Value); + if (resolvedPictures.Intent != null) + { + var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, resolvedPictures.Pictures!.OriginalName); + var publishResult = await PublishCreatePlayListEventAsync(playList.Id, resolvedPictures.Intent, finalPictureKey, cancellationToken); + if (publishResult.IsError) + return publishResult.Errors; } - private async Task> PublishCreatePlayListEventAsync( - Guid playListId, - UploadIntent pictureIntent, - string finalPictureKey, - CancellationToken cancellationToken) + try { - try - { - await eventBus.PublishAsync( - new CreatePlayListResourcesEvent( - playListId, - pictureIntent.Id, - storageConfiguration.Bucket, - pictureIntent.Key, - finalPictureKey, - new ImageSize( - playListConfiguration.Routes.SmallPicturesPath, - playListConfiguration.PicturesSizes.SmallPictureWidth, - playListConfiguration.PicturesSizes.SmallPictureHeight), - new ImageSize( - playListConfiguration.Routes.MediumPicturesPath, - playListConfiguration.PicturesSizes.MediumPictureWidth, - playListConfiguration.PicturesSizes.MediumPictureHeight), - new ImageSize( - playListConfiguration.Routes.LargePicturesPath, - playListConfiguration.PicturesSizes.LargePictureWidth, - playListConfiguration.PicturesSizes.LargePictureHeight)), - cancellationToken); - return new Success(); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to publish create playlist event for playlist {PlayListId}", playListId); - return Error.Failure(description: $"Failed to publish create playlist event for playlist {playListId}"); - } + await database.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to save playlist for user {UserId}", request.UserId); + return Error.Failure(description: $"Failed to create playlist for user {request.UserId}"); + } + + logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId); + return PlayListApplicationResponse.FromEntity(playList); + } + + private async ValueTask> ResolvePicturesAsync( + CreatePlayListCommand request, CancellationToken cancellationToken) + { + if (!request.PictureIntentId.HasValue) + { + return new ResolvedPlayListPictures(null, null); + } + + var validation = await uploadIntentValidator.ValidateAndLoadAsync( + uploadIntentConfiguration, request.PictureIntentId.Value, request.UserId, cancellationToken); + + if (validation.IsError) + return validation.Errors; + + return new ResolvedPlayListPictures(new PlayListPictures + { + OriginalName = validation.Value.ObjectName, + SmallName = validation.Value.ObjectName, + MediumName = validation.Value.ObjectName, + LargeName = validation.Value.ObjectName + }, validation.Value); + } + + private async Task> PublishCreatePlayListEventAsync( + Guid playListId, + UploadIntent pictureIntent, + string finalPictureKey, + CancellationToken cancellationToken) + { + try + { + await eventBus.PublishAsync( + new CreatePlayListResourcesEvent( + playListId, + pictureIntent.Id, + storageConfiguration.Bucket, + pictureIntent.Key, + finalPictureKey, + new ImageSize( + playListConfiguration.Routes.SmallPicturesPath, + playListConfiguration.PicturesSizes.SmallPictureWidth, + playListConfiguration.PicturesSizes.SmallPictureHeight), + new ImageSize( + playListConfiguration.Routes.MediumPicturesPath, + playListConfiguration.PicturesSizes.MediumPictureWidth, + playListConfiguration.PicturesSizes.MediumPictureHeight), + new ImageSize( + playListConfiguration.Routes.LargePicturesPath, + playListConfiguration.PicturesSizes.LargePictureWidth, + playListConfiguration.PicturesSizes.LargePictureHeight)), + cancellationToken); + return new Success(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to publish create playlist event for playlist {PlayListId}", playListId); + return Error.Failure(description: $"Failed to publish create playlist event for playlist {playListId}"); } } } diff --git a/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs b/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs index 51b4fd5..066649a 100644 --- a/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/DeletePlayList.cs @@ -6,57 +6,56 @@ using Musify.Application.Contracts; using Musify.Application.Events; using Musify.Domain.ValueObjects; -namespace Musify.Application.PlayLists +namespace Musify.Application.PlayLists; + +public record DeletePlayListCommand(long UserId, Guid PlayListId) + : ICommand>; + +public class DeletePlayListCommandHandler( + IDatabase database, + IEventBus eventBus, + ILogger logger) + : ICommandHandler> { - public record DeletePlayListCommand(long UserId, Guid PlayListId) - : ICommand>; - - public class DeletePlayListCommandHandler( - IDatabase database, - IEventBus eventBus, - ILogger logger) - : ICommandHandler> + public async ValueTask> Handle(DeletePlayListCommand request, CancellationToken cancellationToken) { - public async ValueTask> Handle(DeletePlayListCommand request, CancellationToken cancellationToken) + var playList = await database.PlayLists.SingleOrDefaultAsync(p => p.Id == request.PlayListId, cancellationToken); + if (playList == null) { - var playList = await database.PlayLists.SingleOrDefaultAsync(p => p.Id == request.PlayListId, cancellationToken); - if (playList == null) - { - logger.LogDebug("Playlist {PlayListId} not found", request.PlayListId); - return Error.NotFound(); - } - - if (playList.UserId != request.UserId) - { - logger.LogWarning("Playlist {PlayListId} does not belong to user {UserId}", request.PlayListId, request.UserId); - return Error.Unauthorized(); - } - - playList.LifeCycleStatus = LifeCycleStatus.Removing; - - try - { - await eventBus.PublishAsync( - new DeletePlayListEvent(playList.Id, request.UserId), - cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to publish delete playlist event for playlist {PlayListId}", request.PlayListId); - return Error.Failure(description: $"Failed to delete playlist {request.PlayListId}"); - } - - try - { - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to mark playlist {PlayListId} as removing", request.PlayListId); - return Error.Failure(description: $"Failed to delete playlist {request.PlayListId}"); - } - - return new Success(); + logger.LogDebug("Playlist {PlayListId} not found", request.PlayListId); + return Error.NotFound(); } + + if (playList.UserId != request.UserId) + { + logger.LogWarning("Playlist {PlayListId} does not belong to user {UserId}", request.PlayListId, request.UserId); + return Error.Unauthorized(); + } + + playList.LifeCycleStatus = LifeCycleStatus.Removing; + + try + { + await eventBus.PublishAsync( + new DeletePlayListEvent(playList.Id, request.UserId), + cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to publish delete playlist event for playlist {PlayListId}", request.PlayListId); + return Error.Failure(description: $"Failed to delete playlist {request.PlayListId}"); + } + + try + { + await database.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to mark playlist {PlayListId} as removing", request.PlayListId); + return Error.Failure(description: $"Failed to delete playlist {request.PlayListId}"); + } + + return new Success(); } } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs index 0fc11b7..506918d 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListById.cs @@ -1,42 +1,43 @@ using ErrorOr; -using Microsoft.EntityFrameworkCore; -using Musify.Application.PlayLists.Responses; using Mediator; +using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; +using Musify.Application.PlayLists.Responses; using Musify.Domain.ValueObjects; -namespace Musify.Application.PlayLists +namespace Musify.Application.PlayLists; + +public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null) + : IQuery>; + +public class GetPlayListByIdQueryHandler(IDatabase database) + : IQueryHandler> { - public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null) - : IQuery>; - - public class GetPlayListByIdQueryHandler(IDatabase database) - : IQueryHandler> + public async ValueTask> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken) { - public async ValueTask> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken) + var entry = await database.PlayLists + .AsNoTracking() + .Where(p => p.Id == request.Id) + .Select(p => new + { + PlayList = p, + CoverTrackIds = p.PlayListTracks + .OrderBy(plt => plt.Position) + .Take(PlayListApplicationResponse.CoverTrackCount) + .Select(plt => plt.TrackId) + .ToList() + }) + .SingleOrDefaultAsync(cancellationToken); + + if (entry == null) + return Error.NotFound(); + + if (entry.PlayList.Visibility == PlaylistVisibility.Private + && entry.PlayList.UserId != request.RequestingUserId) { - var entry = await database.PlayLists - .AsNoTracking() - .Where(p => p.Id == request.Id) - .Select(p => new - { - PlayList = p, - CoverTrackIds = p.PlayListTracks - .OrderBy(plt => plt.Position) - .Take(PlayListApplicationResponse.CoverTrackCount) - .Select(plt => plt.TrackId) - .ToList() - }) - .SingleOrDefaultAsync(cancellationToken); - - if (entry == null) - return Error.NotFound(); - - if (entry.PlayList.Visibility == PlaylistVisibility.Private - && entry.PlayList.UserId != request.RequestingUserId) - return Error.NotFound(); - - return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds); + return Error.NotFound(); } + + return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds); } } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs index 312d609..3e739ae 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListCover.cs @@ -5,45 +5,44 @@ using MimeMapping; using Musify.Application.Configuration; using Musify.Application.Contracts; -namespace Musify.Application.PlayLists +namespace Musify.Application.PlayLists; + +public record GetPlayListCoverQuery(Guid PlayListId, string Size) + : IQuery>; + +public record PlayListCoverLocation(string Bucket, string Key, string ContentType); + +public class GetPlayListCoverQueryHandler( + IDatabase database, + ApplicationStorageConfiguration storageConfiguration, + PlayListConfiguration playListConfiguration) + : IQueryHandler> { - public record GetPlayListCoverQuery(Guid PlayListId, string Size) - : IQuery>; - - public record PlayListCoverLocation(string Bucket, string Key, string ContentType); - - public class GetPlayListCoverQueryHandler( - IDatabase database, - ApplicationStorageConfiguration storageConfiguration, - PlayListConfiguration playListConfiguration) - : IQueryHandler> + public async ValueTask> Handle(GetPlayListCoverQuery request, CancellationToken cancellationToken) { - public async ValueTask> Handle(GetPlayListCoverQuery request, CancellationToken cancellationToken) + var pictures = await database.PlayLists.AsNoTracking() + .Where(p => p.Id == request.PlayListId) + .Select(p => p.Pictures) + .SingleOrDefaultAsync(cancellationToken); + + if (pictures == null) + return Error.NotFound(); + + var routes = playListConfiguration.Routes; + var (name, key) = request.Size.ToLowerInvariant() switch { - var pictures = await database.PlayLists.AsNoTracking() - .Where(p => p.Id == request.PlayListId) - .Select(p => p.Pictures) - .SingleOrDefaultAsync(cancellationToken); + "small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)), + "large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)), + _ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName)) + }; - if (pictures == null) - return Error.NotFound(); + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) + return Error.NotFound(); - var routes = playListConfiguration.Routes; - var (name, key) = request.Size.ToLowerInvariant() switch - { - "small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)), - "large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)), - _ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName)) - }; - - if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key)) - return Error.NotFound(); - - var contentType = MimeUtility.GetMimeMapping(name); - return new PlayListCoverLocation(storageConfiguration.Bucket, key, contentType); - } - - private static string? BuildKey(Func builder, string? name) - => string.IsNullOrEmpty(name) ? null : builder(name); + var contentType = MimeUtility.GetMimeMapping(name); + return new PlayListCoverLocation(storageConfiguration.Bucket, key, contentType); } + + private static string? BuildKey(Func builder, string? name) + => string.IsNullOrEmpty(name) ? null : builder(name); } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs index b26a3fc..1850462 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs @@ -7,43 +7,42 @@ using Musify.Application.Tracks.Responses; using X.PagedList; using X.PagedList.EF; -namespace Musify.Application.PlayLists +namespace Musify.Application.PlayLists; + +public record GetPlayListTracksQuery( + Guid PlayListId, + int PageNumber, + int PageSize) + : IQuery>>; + +public class GetPlayListTracksQueryHandler(IDatabase database) + : IQueryHandler>> { - public record GetPlayListTracksQuery( - Guid PlayListId, - int PageNumber, - int PageSize) - : IQuery>>; - - public class GetPlayListTracksQueryHandler(IDatabase database) - : IQueryHandler>> + public async ValueTask>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken) { - public async ValueTask>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken) - { - var playListExists = await database.PlayLists - .AsNoTracking() - .AnyAsync(p => p.Id == request.PlayListId, cancellationToken); - if (!playListExists) - return Error.NotFound(); + var playListExists = await database.PlayLists + .AsNoTracking() + .AnyAsync(p => p.Id == request.PlayListId, cancellationToken); + if (!playListExists) + return Error.NotFound(); - var tracksQuery = database.PlayListHasTracks - .AsNoTracking() - .Include(plt => plt.Track.Owner) - .Include(plt => plt.Track.Tags) - .Where(plt => plt.PlayListId == request.PlayListId); + var tracksQuery = database.PlayListHasTracks + .AsNoTracking() + .Include(plt => plt.Track.Owner) + .Include(plt => plt.Track.Tags) + .Where(plt => plt.PlayListId == request.PlayListId); - var totalCount = await tracksQuery.CountAsync(cancellationToken); + var totalCount = await tracksQuery.CountAsync(cancellationToken); - var pagedEntities = await tracksQuery - .OrderBy(plt => plt.Position) - .Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count(l => l.IsCounted) }) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + var pagedEntities = await tracksQuery + .OrderBy(plt => plt.Position) + .Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count(l => l.IsCounted) }) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - var pagedTracks = new StaticPagedList( - pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)), - pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); + var pagedTracks = new StaticPagedList( + pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)), + pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); - return PaginatedResponse.FromPagedList(pagedTracks); - } + return PaginatedResponse.FromPagedList(pagedTracks); } } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayLists.cs b/backend/Core/Musify.Application/PlayLists/GetPlayLists.cs index 07d8874..1b22591 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayLists.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayLists.cs @@ -1,43 +1,42 @@ using ErrorOr; +using Mediator; using Microsoft.EntityFrameworkCore; -using Musify.Application.Shared; +using Musify.Application.Contracts; using Musify.Application.PlayLists.Responses; +using Musify.Application.Shared; using X.PagedList; using X.PagedList.EF; -using Mediator; -using Musify.Application.Contracts; -namespace Musify.Application.PlayLists +namespace Musify.Application.PlayLists; + +public record GetPlayListsQuery(int PageNumber, int PageSize) + : IQuery>>; + +public class GetPlayListsQueryHandler(IDatabase database) + : IQueryHandler>> { - public record GetPlayListsQuery(int PageNumber, int PageSize) - : IQuery>>; - - public class GetPlayListsQueryHandler(IDatabase database) - : IQueryHandler>> + public async ValueTask>> Handle(GetPlayListsQuery request, CancellationToken cancellationToken) { - public async ValueTask>> Handle(GetPlayListsQuery request, CancellationToken cancellationToken) - { - var totalCount = await database.PlayLists.CountAsync(cancellationToken); + var totalCount = await database.PlayLists.CountAsync(cancellationToken); - var pagedEntities = await database.PlayLists - .AsNoTracking() - .OrderBy(p => p.Id) - .Select(p => new - { - PlayList = p, - CoverTrackIds = p.PlayListTracks - .OrderBy(plt => plt.Position) - .Take(PlayListApplicationResponse.CoverTrackCount) - .Select(plt => plt.TrackId) - .ToList() - }) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + var pagedEntities = await database.PlayLists + .AsNoTracking() + .OrderBy(p => p.Id) + .Select(p => new + { + PlayList = p, + CoverTrackIds = p.PlayListTracks + .OrderBy(plt => plt.Position) + .Take(PlayListApplicationResponse.CoverTrackCount) + .Select(plt => plt.TrackId) + .ToList() + }) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - var pagedPlayLists = new StaticPagedList( - pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)), - pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); + var pagedPlayLists = new StaticPagedList( + pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)), + pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); - return PaginatedResponse.FromPagedList(pagedPlayLists); - } + return PaginatedResponse.FromPagedList(pagedPlayLists); } } diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs index f877f7e..57f5817 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs @@ -8,57 +8,56 @@ using Musify.Domain.ValueObjects; using X.PagedList; using X.PagedList.EF; -namespace Musify.Application.PlayLists +namespace Musify.Application.PlayLists; + +public record GetPlayListsByUserIdQuery( + long UserId, + string? Name, + int PageNumber, + int PageSize, + bool OnlyPublic = false) + : IQuery>>; + +public class GetPlayListsByUserIdQueryHandler(IDatabase database) + : IQueryHandler>> { - public record GetPlayListsByUserIdQuery( - long UserId, - string? Name, - int PageNumber, - int PageSize, - bool OnlyPublic = false) - : IQuery>>; - - public class GetPlayListsByUserIdQueryHandler(IDatabase database) - : IQueryHandler>> + public async ValueTask>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken) { - public async ValueTask>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken) + var playListsQuery = database.PlayLists + .AsNoTracking() + .Where(p => p.UserId == request.UserId); + + if (request.OnlyPublic) { - var playListsQuery = database.PlayLists - .AsNoTracking() - .Where(p => p.UserId == request.UserId); - - if (request.OnlyPublic) - { - playListsQuery = playListsQuery.Where(p => p.Visibility == PlaylistVisibility.Public); - } - - if (!string.IsNullOrEmpty(request.Name)) - { - var normalizedName = request.Name.Trim().ToUpperInvariant(); - playListsQuery = playListsQuery.Where(p => p.NormalizedName.Contains(normalizedName)); - } - - var totalCount = await playListsQuery.CountAsync(cancellationToken); - - var pagedEntities = await playListsQuery - .OrderBy(p => p.CreatedAt) - .Where(p => p.LifeCycleStatus == LifeCycleStatus.Active) - .Select(p => new - { - PlayList = p, - CoverTrackIds = p.PlayListTracks - .OrderBy(plt => plt.Position) - .Take(PlayListApplicationResponse.CoverTrackCount) - .Select(plt => plt.TrackId) - .ToList() - }) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - - var pagedPlayLists = new StaticPagedList( - pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)), - pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); - - return PaginatedResponse.FromPagedList(pagedPlayLists); + playListsQuery = playListsQuery.Where(p => p.Visibility == PlaylistVisibility.Public); } + + if (!string.IsNullOrEmpty(request.Name)) + { + var normalizedName = request.Name.Trim().ToUpperInvariant(); + playListsQuery = playListsQuery.Where(p => p.NormalizedName.Contains(normalizedName)); + } + + var totalCount = await playListsQuery.CountAsync(cancellationToken); + + var pagedEntities = await playListsQuery + .OrderBy(p => p.CreatedAt) + .Where(p => p.LifeCycleStatus == LifeCycleStatus.Active) + .Select(p => new + { + PlayList = p, + CoverTrackIds = p.PlayListTracks + .OrderBy(plt => plt.Position) + .Take(PlayListApplicationResponse.CoverTrackCount) + .Select(plt => plt.TrackId) + .ToList() + }) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + + var pagedPlayLists = new StaticPagedList( + pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)), + pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); + + return PaginatedResponse.FromPagedList(pagedPlayLists); } } diff --git a/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs b/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs index 1202324..6a9ac11 100644 --- a/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/RemoveTrackFromPlayList.cs @@ -4,46 +4,45 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; -namespace Musify.Application.PlayLists +namespace Musify.Application.PlayLists; + +public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId) + : ICommand>; + +public class RemoveTrackFromPlayListCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> { - public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId) - : ICommand>; - - public class RemoveTrackFromPlayListCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> + public async ValueTask> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken) { - public async ValueTask> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken) + var playList = await database.PlayLists + .SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); + if (playList == null) { - var playList = await database.PlayLists - .SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); - if (playList == null) - { - logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId); - return Error.NotFound(); - } - - if (playList.UserId != request.UserId) - { - logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); - return Error.Unauthorized(); - } - - var link = await database.PlayListHasTracks - .SingleOrDefaultAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken); - if (link == null) - { - logger.LogInformation("Track {TrackId} not in playlist {PlayListId}", request.TrackId, request.PlayListId); - return Error.NotFound(description: "Track is not in the playlist."); - } - - database.PlayListHasTracks.Remove(link); - await database.SaveChangesAsync(cancellationToken); - - logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId); - - return new Success(); + logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId); + return Error.NotFound(); } + + if (playList.UserId != request.UserId) + { + logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); + return Error.Unauthorized(); + } + + var link = await database.PlayListHasTracks + .SingleOrDefaultAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken); + if (link == null) + { + logger.LogInformation("Track {TrackId} not in playlist {PlayListId}", request.TrackId, request.PlayListId); + return Error.NotFound(description: "Track is not in the playlist."); + } + + database.PlayListHasTracks.Remove(link); + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId); + + return new Success(); } } diff --git a/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs b/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs index dbf519d..256ea6d 100644 --- a/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs +++ b/backend/Core/Musify.Application/PlayLists/RequestPlayListPictureUpload.cs @@ -3,103 +3,102 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Configuration; +using Musify.Application.Contracts; using Musify.Application.PlayLists.Responses; using Musify.Application.Services; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -using Musify.Application.Contracts; -namespace Musify.Application.PlayLists +namespace Musify.Application.PlayLists; + +public record RequestPlayListPictureUploadCommand( + long UserId, + string FileType, + string ContentType, + long? ExpectedSizeBytes = null) + : ICommand>; + +public class RequestPlayListPictureUploadCommandHandler( + IDatabase database, + UploadIntentValidator uploadIntentValidator, + IStorageService storageService, + ILogger logger, + ApplicationStorageConfiguration storageConfiguration, + PlayListConfiguration playListConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) + : ICommandHandler> { - public record RequestPlayListPictureUploadCommand( - long UserId, - string FileType, - string ContentType, - long? ExpectedSizeBytes = null) - : ICommand>; - - public class RequestPlayListPictureUploadCommandHandler( - IDatabase database, - UploadIntentValidator uploadIntentValidator, - IStorageService storageService, - ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - PlayListConfiguration playListConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) - : ICommandHandler> + public async ValueTask> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) { - public async ValueTask> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(u => u.Id == request.UserId, cancellationToken); + if (!userExists) { - var userExists = await database.Users - .AsNoTracking() - .AnyAsync(u => u.Id == request.UserId, cancellationToken); - if (!userExists) + logger.LogWarning("User {UserId} not found", request.UserId); + return Error.NotFound(description: $"User {request.UserId} not found"); + } + + var effectiveSizeBytes = request.ExpectedSizeBytes + ?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes; + + var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}"; + + var tempKey = playListConfiguration.Routes.BuildTempPicturePath( + uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName); + + try + { + var uploadUrl = await storageService.GetUploadUrlAsync( + storageConfiguration.Bucket, + tempKey, + request.ContentType, + TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds), + cancellationToken); + + await using var transaction = await database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, cancellationToken); + + var quotaCheck = await uploadIntentValidator.CheckQuotaAsync( + uploadIntentConfiguration, + request.UserId, effectiveSizeBytes, 1, cancellationToken); + if (quotaCheck.IsError) { - logger.LogWarning("User {UserId} not found", request.UserId); - return Error.NotFound(description: $"User {request.UserId} not found"); + logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId); + return quotaCheck.Errors; } - var effectiveSizeBytes = request.ExpectedSizeBytes - ?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes; - - var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}"; - - var tempKey = playListConfiguration.Routes.BuildTempPicturePath( - uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName); - - try + var intent = new UploadIntent { - var uploadUrl = await storageService.GetUploadUrlAsync( - storageConfiguration.Bucket, - tempKey, - request.ContentType, - TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds), - cancellationToken); + UserId = request.UserId, + Bucket = storageConfiguration.Bucket, + Key = tempKey, + ObjectName = objectName, + ContentType = request.ContentType, + ExpectedSizeBytes = effectiveSizeBytes, + Purpose = UploadIntentPurpose.PlayListPicture, + ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds), + }; - await using var transaction = await database.BeginTransactionAsync( - System.Data.IsolationLevel.Serializable, cancellationToken); + await database.UploadIntents.AddAsync(intent, cancellationToken); + await database.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); - var quotaCheck = await uploadIntentValidator.CheckQuotaAsync( - uploadIntentConfiguration, - request.UserId, effectiveSizeBytes, 1, cancellationToken); - if (quotaCheck.IsError) - { - logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId); - return quotaCheck.Errors; - } + logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (PlayListPicture, temp key: {Key})", intent.Id, request.UserId, tempKey); - var intent = new UploadIntent - { - UserId = request.UserId, - Bucket = storageConfiguration.Bucket, - Key = tempKey, - ObjectName = objectName, - ContentType = request.ContentType, - ExpectedSizeBytes = effectiveSizeBytes, - Purpose = UploadIntentPurpose.PlayListPicture, - ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds), - }; - - await database.UploadIntents.AddAsync(intent, cancellationToken); - await database.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); - - logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (PlayListPicture, temp key: {Key})", intent.Id, request.UserId, tempKey); - - return new PlayListPictureUploadResponse( - intent.Id, - storageConfiguration.Bucket, - tempKey, - objectName, - request.ContentType, - uploadIntentConfiguration.UploadUrlExpiresInSeconds, - uploadUrl); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to generate playlist picture upload URL for user {UserId}", request.UserId); - return Error.Failure(description: "Failed to generate upload URL"); - } + return new PlayListPictureUploadResponse( + intent.Id, + storageConfiguration.Bucket, + tempKey, + objectName, + request.ContentType, + uploadIntentConfiguration.UploadUrlExpiresInSeconds, + uploadUrl); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to generate playlist picture upload URL for user {UserId}", request.UserId); + return Error.Failure(description: "Failed to generate upload URL"); } } } diff --git a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs index 225c5d2..dedb055 100644 --- a/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs +++ b/backend/Core/Musify.Application/PlayLists/Responses/PlayListApplicationResponse.cs @@ -1,37 +1,36 @@ 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) - { - public const int CoverTrackCount = 4; +namespace Musify.Application.PlayLists.Responses; - public static PlayListApplicationResponse FromEntity( - PlayList playList, - IReadOnlyList? coverTrackIds = null) - { - 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 ?? []); - } +public record PlayListApplicationResponse( + Guid Id, + string Name, + string? Description, + string? SmallImageKeyName, + string? MediumImageKeyName, + string? LargeImageKeyName, + PlaylistVisibility Visibility, + DateTime CreatedAt, + DateTime UpdatedAt, + IReadOnlyList CoverTrackIds) +{ + public const int CoverTrackCount = 4; + + public static PlayListApplicationResponse FromEntity( + PlayList playList, + IReadOnlyList? coverTrackIds = null) + { + 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 ?? []); } } diff --git a/backend/Core/Musify.Application/PlayLists/Responses/PlayListPictureUploadResponse.cs b/backend/Core/Musify.Application/PlayLists/Responses/PlayListPictureUploadResponse.cs index 7928680..6937944 100644 --- a/backend/Core/Musify.Application/PlayLists/Responses/PlayListPictureUploadResponse.cs +++ b/backend/Core/Musify.Application/PlayLists/Responses/PlayListPictureUploadResponse.cs @@ -1,11 +1,10 @@ -namespace Musify.Application.PlayLists.Responses -{ - public record PlayListPictureUploadResponse( - Guid IntentId, - string Bucket, - string Key, - string PictureName, - string ContentType, - int ExpiresInSeconds, - string UploadUrl); -} +namespace Musify.Application.PlayLists.Responses; + +public record PlayListPictureUploadResponse( + Guid IntentId, + string Bucket, + string Key, + string PictureName, + string ContentType, + int ExpiresInSeconds, + string UploadUrl); diff --git a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs index 46cc87a..b5effd5 100644 --- a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs @@ -3,153 +3,152 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Configuration; -using Musify.Application.Shared; +using Musify.Application.Contracts; using Musify.Application.Events; using Musify.Application.PlayLists.Responses; using Musify.Application.Services; +using Musify.Application.Shared; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -using Musify.Application.Contracts; -namespace Musify.Application.PlayLists +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, + ApplicationStorageConfiguration storageConfiguration, + PlayListConfiguration playListConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) + : ICommandHandler> { - 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, - ApplicationStorageConfiguration storageConfiguration, - PlayListConfiguration playListConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) - : ICommandHandler> + public async ValueTask> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken) { - public async ValueTask> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken) + var playListEntity = await database.PlayLists.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); + if (playListEntity == null) { - var playListEntity = await database.PlayLists.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken); - if (playListEntity == null) - { - logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId); - return Error.NotFound(); - } - - if (playListEntity.UserId != request.UserId) - { - logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); - return Error.Unauthorized(); - } - - if (!string.IsNullOrWhiteSpace(request.NewName)) - { - playListEntity.Name = request.NewName; - playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant(); - } - - if (!string.IsNullOrWhiteSpace(request.NewDescription)) - { - playListEntity.Description = request.NewDescription; - } - - if (request.NewVisibility.HasValue) - { - playListEntity.Visibility = request.NewVisibility.Value; - } - - UploadIntent? pictureIntent = null; - string? finalPictureKey = null; - - if (request.NewPictureIntentId.HasValue) - { - var validation = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, - request.NewPictureIntentId.Value, request.UserId, cancellationToken); - if (validation.IsError) - return validation.Errors; - - pictureIntent = validation.Value; - playListEntity.Pictures = new PlayListPictures - { - OriginalName = pictureIntent.ObjectName, - SmallName = pictureIntent.ObjectName, - MediumName = pictureIntent.ObjectName, - LargeName = pictureIntent.ObjectName - }; - finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); - } - - if (pictureIntent != null && finalPictureKey != null) - { - var publishResult = await PublishUpdatePlayListPictureSourceEventAsync( - playListEntity.Id, pictureIntent, finalPictureKey, cancellationToken); - if (publishResult.IsError) - return publishResult.Errors; - } - - try - { - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to update playlist {PlayListId}", playListEntity.Id); - return Error.Failure(description: $"Failed to update playlist {playListEntity.Id}"); - } - - var coverTrackIds = await database.PlayListHasTracks - .AsNoTracking() - .Where(plt => plt.PlayListId == playListEntity.Id) - .OrderBy(plt => plt.Position) - .Take(PlayListApplicationResponse.CoverTrackCount) - .Select(plt => plt.TrackId) - .ToListAsync(cancellationToken); - - logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id); - return PlayListApplicationResponse.FromEntity(playListEntity, coverTrackIds); + logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId); + return Error.NotFound(); } - private async Task> PublishUpdatePlayListPictureSourceEventAsync( - Guid playListId, - UploadIntent pictureIntent, - string finalPictureKey, - CancellationToken cancellationToken) + if (playListEntity.UserId != request.UserId) { - try + logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId); + return Error.Unauthorized(); + } + + if (!string.IsNullOrWhiteSpace(request.NewName)) + { + playListEntity.Name = request.NewName; + playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant(); + } + + if (!string.IsNullOrWhiteSpace(request.NewDescription)) + { + playListEntity.Description = request.NewDescription; + } + + if (request.NewVisibility.HasValue) + { + playListEntity.Visibility = request.NewVisibility.Value; + } + + UploadIntent? pictureIntent = null; + string? finalPictureKey = null; + + if (request.NewPictureIntentId.HasValue) + { + var validation = await uploadIntentValidator.ValidateAndLoadAsync( + uploadIntentConfiguration, + request.NewPictureIntentId.Value, request.UserId, cancellationToken); + if (validation.IsError) + return validation.Errors; + + pictureIntent = validation.Value; + playListEntity.Pictures = new PlayListPictures { - await eventBus.PublishAsync( - new UpdatePlayListPictureSourceEvent( - playListId, - pictureIntent.Id, - storageConfiguration.Bucket, - pictureIntent.Key, - finalPictureKey, - new ImageSize( - playListConfiguration.Routes.SmallPicturesPath, - playListConfiguration.PicturesSizes.SmallPictureWidth, - playListConfiguration.PicturesSizes.SmallPictureHeight), - new ImageSize( - playListConfiguration.Routes.MediumPicturesPath, - playListConfiguration.PicturesSizes.MediumPictureWidth, - playListConfiguration.PicturesSizes.MediumPictureHeight), - new ImageSize( - playListConfiguration.Routes.LargePicturesPath, - playListConfiguration.PicturesSizes.LargePictureWidth, - playListConfiguration.PicturesSizes.LargePictureHeight)), - cancellationToken); - return new Success(); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to publish update playlist picture source event for playlist {PlayListId}", playListId); - return Error.Failure(description: $"Failed to publish update playlist picture source event for playlist {playListId}"); - } + OriginalName = pictureIntent.ObjectName, + SmallName = pictureIntent.ObjectName, + MediumName = pictureIntent.ObjectName, + LargeName = pictureIntent.ObjectName + }; + finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName); + } + + if (pictureIntent != null && finalPictureKey != null) + { + var publishResult = await PublishUpdatePlayListPictureSourceEventAsync( + playListEntity.Id, pictureIntent, finalPictureKey, cancellationToken); + if (publishResult.IsError) + return publishResult.Errors; + } + + try + { + await database.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to update playlist {PlayListId}", playListEntity.Id); + return Error.Failure(description: $"Failed to update playlist {playListEntity.Id}"); + } + + var coverTrackIds = await database.PlayListHasTracks + .AsNoTracking() + .Where(plt => plt.PlayListId == playListEntity.Id) + .OrderBy(plt => plt.Position) + .Take(PlayListApplicationResponse.CoverTrackCount) + .Select(plt => plt.TrackId) + .ToListAsync(cancellationToken); + + logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id); + return PlayListApplicationResponse.FromEntity(playListEntity, coverTrackIds); + } + + private async Task> PublishUpdatePlayListPictureSourceEventAsync( + Guid playListId, + UploadIntent pictureIntent, + string finalPictureKey, + CancellationToken cancellationToken) + { + try + { + await eventBus.PublishAsync( + new UpdatePlayListPictureSourceEvent( + playListId, + pictureIntent.Id, + storageConfiguration.Bucket, + pictureIntent.Key, + finalPictureKey, + new ImageSize( + playListConfiguration.Routes.SmallPicturesPath, + playListConfiguration.PicturesSizes.SmallPictureWidth, + playListConfiguration.PicturesSizes.SmallPictureHeight), + new ImageSize( + playListConfiguration.Routes.MediumPicturesPath, + playListConfiguration.PicturesSizes.MediumPictureWidth, + playListConfiguration.PicturesSizes.MediumPictureHeight), + new ImageSize( + playListConfiguration.Routes.LargePicturesPath, + playListConfiguration.PicturesSizes.LargePictureWidth, + playListConfiguration.PicturesSizes.LargePictureHeight)), + cancellationToken); + return new Success(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to publish update playlist picture source event for playlist {PlayListId}", playListId); + return Error.Failure(description: $"Failed to publish update playlist picture source event for playlist {playListId}"); } } } diff --git a/backend/Core/Musify.Application/Services/UploadIntentValidator.cs b/backend/Core/Musify.Application/Services/UploadIntentValidator.cs index 7b9bbb4..e37abfb 100644 --- a/backend/Core/Musify.Application/Services/UploadIntentValidator.cs +++ b/backend/Core/Musify.Application/Services/UploadIntentValidator.cs @@ -1,9 +1,9 @@ using ErrorOr; using Microsoft.EntityFrameworkCore; using Musify.Application.Configuration; +using Musify.Application.Contracts; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -using Musify.Application.Contracts; namespace Musify.Application.Services; diff --git a/backend/Core/Musify.Application/Shared/ImageSize.cs b/backend/Core/Musify.Application/Shared/ImageSize.cs index c6f27d2..ba33f19 100644 --- a/backend/Core/Musify.Application/Shared/ImageSize.cs +++ b/backend/Core/Musify.Application/Shared/ImageSize.cs @@ -1,7 +1,6 @@ -namespace Musify.Application.Shared -{ - public record ImageSize( - string SavePath, - int Width, - int Height); -} \ No newline at end of file +namespace Musify.Application.Shared; + +public record ImageSize( + string SavePath, + int Width, + int Height); diff --git a/backend/Core/Musify.Application/Shared/PaginatedResponse.cs b/backend/Core/Musify.Application/Shared/PaginatedResponse.cs index 8bb7c1f..e77284b 100644 --- a/backend/Core/Musify.Application/Shared/PaginatedResponse.cs +++ b/backend/Core/Musify.Application/Shared/PaginatedResponse.cs @@ -1,26 +1,25 @@ using X.PagedList; -namespace Musify.Application.Shared +namespace Musify.Application.Shared; + +public record PaginatedResponse( + IReadOnlyCollection Items, + int PageNumber, + int PageSize, + int PageCount, + int TotalItemCount, + bool HasNextPage, + bool HasPreviousPage) { - public record PaginatedResponse( - IReadOnlyCollection Items, - int PageNumber, - int PageSize, - int PageCount, - int TotalItemCount, - bool HasNextPage, - bool HasPreviousPage) + public static PaginatedResponse FromPagedList(IPagedList pagedList) { - public static PaginatedResponse FromPagedList(IPagedList pagedList) - { - return new PaginatedResponse( - pagedList.ToArray(), - pagedList.PageNumber, - pagedList.PageSize, - pagedList.PageCount, - pagedList.TotalItemCount, - pagedList.HasNextPage, - pagedList.HasPreviousPage); - } + return new PaginatedResponse( + pagedList.ToArray(), + pagedList.PageNumber, + pagedList.PageSize, + pagedList.PageCount, + pagedList.TotalItemCount, + pagedList.HasNextPage, + pagedList.HasPreviousPage); } } diff --git a/backend/Core/Musify.Application/Shared/StorageKey.cs b/backend/Core/Musify.Application/Shared/StorageKey.cs index 82ddca8..4837207 100644 --- a/backend/Core/Musify.Application/Shared/StorageKey.cs +++ b/backend/Core/Musify.Application/Shared/StorageKey.cs @@ -1,12 +1,11 @@ -namespace Musify.Application.Shared +namespace Musify.Application.Shared; + +public static class StorageKey { - public static class StorageKey + public static string Combine(params string[] segments) { - public static string Combine(params string[] segments) - { - return string.Join('/', segments - .Where(static s => !string.IsNullOrWhiteSpace(s)) - .Select(static s => s.Trim().Trim('/', '\\'))); - } + return string.Join('/', segments + .Where(static s => !string.IsNullOrWhiteSpace(s)) + .Select(static s => s.Trim().Trim('/', '\\'))); } } diff --git a/backend/Core/Musify.Application/Tracks/CreateTrack.cs b/backend/Core/Musify.Application/Tracks/CreateTrack.cs index 4e37204..139cc53 100644 --- a/backend/Core/Musify.Application/Tracks/CreateTrack.cs +++ b/backend/Core/Musify.Application/Tracks/CreateTrack.cs @@ -3,159 +3,158 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Configuration; -using Musify.Application.Shared; +using Musify.Application.Contracts; using Musify.Application.Events; -using Musify.Application.Tracks.Responses; using Musify.Application.Services; +using Musify.Application.Shared; +using Musify.Application.Tracks.Responses; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -using Musify.Application.Contracts; -namespace Musify.Application.Tracks +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, + ApplicationStorageConfiguration storageConfiguration, + TrackConfiguration trackConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) + : ICommandHandler> { - 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, - ApplicationStorageConfiguration storageConfiguration, - TrackConfiguration trackConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) - : ICommandHandler> + public async ValueTask> Handle(CreateTrackCommand request, CancellationToken cancellationToken) { - public async ValueTask> Handle(CreateTrackCommand request, CancellationToken cancellationToken) + var user = await database.Users + .SingleOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); + if (user == null) { - var user = await database.Users - .SingleOrDefaultAsync(u => u.Id == request.UserId, cancellationToken); - if (user == null) - { - logger.LogWarning("User {UserId} not found", request.UserId); - return Error.NotFound(description: $"User {request.UserId} not found"); - } - - var distinctTags = request.Tags.Distinct().ToList(); - if (distinctTags.Count == 0) - { - logger.LogWarning("Track {Title} was submitted without any tags", request.Title); - return Error.Validation(description: "At least one tag is required."); - } - - var tagConflicts = GenreCompatibility.FindConflicts(distinctTags); - if (tagConflicts.Count > 0) - { - var conflict = tagConflicts.First(); - logger.LogWarning( - "Track {Title} was submitted with incompatible tags {First} and {Second}", - request.Title, conflict.First, conflict.Second); - return Error.Validation(description: $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible."); - } - - var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, - request.PictureIntentId, request.UserId, cancellationToken); - if (pictureValidation.IsError) - return pictureValidation.Errors; - - var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, - request.AudioIntentId, request.UserId, 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, - NormalizedTitle = request.Title.ToUpperInvariant(), - 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); - - await database.UserHasTracks.AddAsync(new UserHasTrack - { - UserId = request.UserId, - TrackId = trackEntity.Id - }, cancellationToken); - - try - { - await eventBus.PublishAsync( - new CreateTrackResourcesEvent( - trackEntity.Id, - pictureIntent.Id, - audioIntent.Id, - storageConfiguration.Bucket, - pictureIntent.Key, - finalPictureKey, - audioIntent.Key, - finalAudioKey, - audioProcessedFolderKey, - new ImageSize( - trackConfiguration.Routes.SmallPicturesPath, - trackConfiguration.PicturesSizes.SmallPictureWidth, - trackConfiguration.PicturesSizes.SmallPictureHeight), - new ImageSize( - trackConfiguration.Routes.MediumPicturesPath, - trackConfiguration.PicturesSizes.MediumPictureWidth, - trackConfiguration.PicturesSizes.MediumPictureHeight), - new ImageSize( - trackConfiguration.Routes.LargePicturesPath, - trackConfiguration.PicturesSizes.LargePictureWidth, - trackConfiguration.PicturesSizes.LargePictureHeight)), - cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to publish create track event for track {TrackId}", trackEntity.Id); - return Error.Failure(description: $"Failed to create track for {request.Title}"); - } - - try - { - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to save track {Title}", request.Title); - return Error.Failure(description: $"Failed to create track for {request.Title}"); - } - - return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0); + logger.LogWarning("User {UserId} not found", request.UserId); + return Error.NotFound(description: $"User {request.UserId} not found"); } + + var distinctTags = request.Tags.Distinct().ToList(); + if (distinctTags.Count == 0) + { + logger.LogWarning("Track {Title} was submitted without any tags", request.Title); + return Error.Validation(description: "At least one tag is required."); + } + + var tagConflicts = GenreCompatibility.FindConflicts(distinctTags); + if (tagConflicts.Count > 0) + { + var conflict = tagConflicts.First(); + logger.LogWarning( + "Track {Title} was submitted with incompatible tags {First} and {Second}", + request.Title, conflict.First, conflict.Second); + return Error.Validation(description: $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible."); + } + + var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync( + uploadIntentConfiguration, + request.PictureIntentId, request.UserId, cancellationToken); + if (pictureValidation.IsError) + return pictureValidation.Errors; + + var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync( + uploadIntentConfiguration, + request.AudioIntentId, request.UserId, 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, + NormalizedTitle = request.Title.ToUpperInvariant(), + 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); + + await database.UserHasTracks.AddAsync(new UserHasTrack + { + UserId = request.UserId, + TrackId = trackEntity.Id + }, cancellationToken); + + try + { + await eventBus.PublishAsync( + new CreateTrackResourcesEvent( + trackEntity.Id, + pictureIntent.Id, + audioIntent.Id, + storageConfiguration.Bucket, + pictureIntent.Key, + finalPictureKey, + audioIntent.Key, + finalAudioKey, + audioProcessedFolderKey, + new ImageSize( + trackConfiguration.Routes.SmallPicturesPath, + trackConfiguration.PicturesSizes.SmallPictureWidth, + trackConfiguration.PicturesSizes.SmallPictureHeight), + new ImageSize( + trackConfiguration.Routes.MediumPicturesPath, + trackConfiguration.PicturesSizes.MediumPictureWidth, + trackConfiguration.PicturesSizes.MediumPictureHeight), + new ImageSize( + trackConfiguration.Routes.LargePicturesPath, + trackConfiguration.PicturesSizes.LargePictureWidth, + trackConfiguration.PicturesSizes.LargePictureHeight)), + cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to publish create track event for track {TrackId}", trackEntity.Id); + return Error.Failure(description: $"Failed to create track for {request.Title}"); + } + + try + { + await database.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to save track {Title}", request.Title); + return Error.Failure(description: $"Failed to create track for {request.Title}"); + } + + 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 bdd7c39..7cdfaef 100644 --- a/backend/Core/Musify.Application/Tracks/DeleteStaleUncountedListens.cs +++ b/backend/Core/Musify.Application/Tracks/DeleteStaleUncountedListens.cs @@ -3,31 +3,30 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; -namespace Musify.Application.Tracks +namespace Musify.Application.Tracks; + +public record DeleteStaleUncountedListensCommand : ICommand; + +public class DeleteStaleUncountedListensCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler { - public record DeleteStaleUncountedListensCommand : ICommand; - - public class DeleteStaleUncountedListensCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler + public async ValueTask Handle(DeleteStaleUncountedListensCommand request, CancellationToken cancellationToken) { - public async ValueTask Handle(DeleteStaleUncountedListensCommand request, CancellationToken cancellationToken) - { - var cutoff = DateTime.UtcNow.AddDays(-ListeningRules.UncountedRetentionDays); + 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 9756b3b..a4b0982 100644 --- a/backend/Core/Musify.Application/Tracks/DeleteTrack.cs +++ b/backend/Core/Musify.Application/Tracks/DeleteTrack.cs @@ -2,70 +2,68 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using Musify.Domain.Entities; -using Musify.Domain.ValueObjects; -using Musify.Application.Events; using Musify.Application.Contracts; +using Musify.Application.Events; +using Musify.Domain.ValueObjects; -namespace Musify.Application.Tracks +namespace Musify.Application.Tracks; + +public record DeleteTrackCommand( + long UserId, + Guid TrackId) + : ICommand>; + +public class DeleteTrackCommandHandler( + IDatabase database, + IEventBus eventBus, + ILogger logger) + : ICommandHandler> { - public record DeleteTrackCommand( - long UserId, - Guid TrackId) - : ICommand>; - - public class DeleteTrackCommandHandler( - IDatabase database, - IEventBus eventBus, - ILogger logger) - : ICommandHandler> + public async ValueTask> Handle(DeleteTrackCommand request, CancellationToken cancellationToken) { - public async ValueTask> Handle(DeleteTrackCommand request, CancellationToken cancellationToken) + var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken); + if (track == null) { - var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken); - if (track == null) - { - logger.LogInformation("Track {TrackId} not found", request.TrackId); - return Error.NotFound(); - } - - if (track.OwnerUserId != request.UserId) - { - logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId); - return Error.Unauthorized(); - } - - if (track.Audio.IsInProgress) - { - logger.LogWarning("Track {TrackId} is currently being processed and cannot be deleted", request.TrackId); - return Error.Conflict(description: "Track is currently being processed and cannot be deleted"); - } - - track.LifeCycleStatus = LifeCycleStatus.Removing; - - try - { - await eventBus.PublishAsync( - new DeleteTrackEvent(track.Id, request.UserId), - cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to publish delete track event for track {TrackId}", request.TrackId); - return Error.Failure(description: $"Failed to delete track {request.TrackId}"); - } - - try - { - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to mark track {TrackId} as processing before deletion", request.TrackId); - return Error.Failure(description: $"Failed to delete track {request.TrackId}"); - } - - return new Success(); + logger.LogInformation("Track {TrackId} not found", request.TrackId); + return Error.NotFound(); } + + if (track.OwnerUserId != request.UserId) + { + logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId); + return Error.Unauthorized(); + } + + if (track.Audio.IsInProgress) + { + logger.LogWarning("Track {TrackId} is currently being processed and cannot be deleted", request.TrackId); + return Error.Conflict(description: "Track is currently being processed and cannot be deleted"); + } + + track.LifeCycleStatus = LifeCycleStatus.Removing; + + try + { + await eventBus.PublishAsync( + new DeleteTrackEvent(track.Id, request.UserId), + cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to publish delete track event for track {TrackId}", request.TrackId); + return Error.Failure(description: $"Failed to delete track {request.TrackId}"); + } + + try + { + await database.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to mark track {TrackId} as processing before deletion", request.TrackId); + return Error.Failure(description: $"Failed to delete track {request.TrackId}"); + } + + return new Success(); } } diff --git a/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs b/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs index 8cee026..030fe53 100644 --- a/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs @@ -1,30 +1,29 @@ -using ErrorOr; +using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; -namespace Musify.Application.Tracks +namespace Musify.Application.Tracks; + +public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery>; + +public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database) + : IQueryHandler> { - public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery>; - - public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database) - : IQueryHandler> + public async ValueTask> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken) { - public async ValueTask> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken) - { - var track = await database.ListeningHistories - .AsNoTracking() - .Include(t => t.Track.Owner) - .Include(t => t.Track.Tags) - .Where(t => t.UserId == query.UserId) - .OrderByDescending(t => t.ListenedAt) - .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted))) - .FirstOrDefaultAsync(cancellationToken); + var track = await database.ListeningHistories + .AsNoTracking() + .Include(t => t.Track.Owner) + .Include(t => t.Track.Tags) + .Where(t => t.UserId == query.UserId) + .OrderByDescending(t => t.ListenedAt) + .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted))) + .FirstOrDefaultAsync(cancellationToken); - return track == null - ? Error.NotFound("Track.NotFound", "No track found for the given user ID.") - : track; - } + return track == null + ? Error.NotFound("Track.NotFound", "No track found for the given user ID.") + : track; } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs index 3da51ea..d61825b 100644 --- a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs +++ b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs @@ -1,40 +1,39 @@ -using Mediator; +using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; -namespace Musify.Application.Tracks +namespace Musify.Application.Tracks; + +public record GetListeningHistoryQuery(long UserId) : IQuery>; + +public class GetListeningHistoryQueryHandler( + IDatabase database) + : IQueryHandler> { - public record GetListeningHistoryQuery(long UserId) : IQuery>; + public const int ListSize = 15; - public class GetListeningHistoryQueryHandler( - IDatabase database) - : IQueryHandler> + public async ValueTask> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken) { - public const int ListSize = 15; + 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)) + .Select(g => g.Key) + .Take(ListSize) + .ToListAsync(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)) - .Select(g => g.Key) - .Take(ListSize) - .ToListAsync(cancellationToken); + var tracksById = await database.Tracks + .AsNoTracking() + .Include(t => t.Owner) + .Include(t => t.Tags) + .Where(t => recentTrackIds.Contains(t.Id)) + .Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) }) + .ToDictionaryAsync(t => t.Id, cancellationToken); - var tracksById = await database.Tracks - .AsNoTracking() - .Include(t => t.Owner) - .Include(t => t.Tags) - .Where(t => recentTrackIds.Contains(t.Id)) - .Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) }) - .ToDictionaryAsync(t => t.Id, cancellationToken); - - return recentTrackIds - .Select(id => tracksById[id]) - .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.ListensCount)); - } + return recentTrackIds + .Select(id => tracksById[id]) + .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.ListensCount)); } } diff --git a/backend/Core/Musify.Application/Tracks/GetTrackById.cs b/backend/Core/Musify.Application/Tracks/GetTrackById.cs index 68eaf5f..9c73316 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackById.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackById.cs @@ -4,27 +4,26 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Tracks.Responses; -namespace Musify.Application.Tracks +namespace Musify.Application.Tracks; + +public record GetTrackByIdQuery(Guid TrackId) + : IQuery>; + +public class GetTrackByIdQueryHandler(IDatabase database) + : IQueryHandler> { - public record GetTrackByIdQuery(Guid TrackId) - : IQuery>; - - public class GetTrackByIdQueryHandler(IDatabase database) - : IQueryHandler> + public async ValueTask> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken) { - public async ValueTask> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken) - { - var entity = await database.Tracks.AsNoTracking() - .Include(t => t.Owner) - .Include(t => t.Tags) - .Where(t => t.Id == request.TrackId) - .Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) }) - .SingleOrDefaultAsync(cancellationToken); + var entity = await database.Tracks.AsNoTracking() + .Include(t => t.Owner) + .Include(t => t.Tags) + .Where(t => t.Id == request.TrackId) + .Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) }) + .SingleOrDefaultAsync(cancellationToken); - if (entity == null) - return Error.NotFound(); + if (entity == null) + return Error.NotFound(); - return TrackApplicationResponse.FromEntity(entity.Track, entity.ListensCount); - } + return TrackApplicationResponse.FromEntity(entity.Track, entity.ListensCount); } } diff --git a/backend/Core/Musify.Application/Tracks/GetTrackCover.cs b/backend/Core/Musify.Application/Tracks/GetTrackCover.cs index 80b00ec..39ab256 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackCover.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackCover.cs @@ -4,50 +4,49 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Configuration; using Musify.Application.Contracts; -namespace Musify.Application.Tracks +namespace Musify.Application.Tracks; + +public record GetTrackCoverQuery(Guid TrackId, string Size) + : IQuery>; + +public record TrackCoverLocation(string Bucket, string Key, string ContentType); + +public class GetTrackCoverQueryHandler( + IDatabase database, + ApplicationStorageConfiguration storageConfiguration, + TrackConfiguration trackConfiguration) + : IQueryHandler> { - public record GetTrackCoverQuery(Guid TrackId, string Size) - : IQuery>; - - public record TrackCoverLocation(string Bucket, string Key, string ContentType); - - public class GetTrackCoverQueryHandler( - IDatabase database, - ApplicationStorageConfiguration storageConfiguration, - TrackConfiguration trackConfiguration) - : IQueryHandler> + public async ValueTask> Handle(GetTrackCoverQuery request, CancellationToken cancellationToken) { - public async ValueTask> Handle(GetTrackCoverQuery request, CancellationToken cancellationToken) + var track = await database.Tracks.AsNoTracking() + .Where(t => t.Id == request.TrackId) + .Select(t => new { t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName }) + .SingleOrDefaultAsync(cancellationToken); + + if (track == null) + return Error.NotFound(); + + var routes = trackConfiguration.Routes; + var (name, key) = request.Size.ToLowerInvariant() switch { - var track = await database.Tracks.AsNoTracking() - .Where(t => t.Id == request.TrackId) - .Select(t => new { t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName }) - .SingleOrDefaultAsync(cancellationToken); + "small" => (track.SmallName, BuildKey(routes.BuildSmallPicturePath, track.SmallName)), + "large" => (track.LargeName, BuildKey(routes.BuildLargePicturePath, track.LargeName)), + _ => (track.MediumName, BuildKey(routes.BuildMediumPicturePath, track.MediumName)) + }; - if (track == null) - return Error.NotFound(); + if (string.IsNullOrEmpty(name) || key == null) + return Error.NotFound(); - var routes = trackConfiguration.Routes; - var (name, key) = request.Size.ToLowerInvariant() switch - { - "small" => (track.SmallName, BuildKey(routes.BuildSmallPicturePath, track.SmallName)), - "large" => (track.LargeName, BuildKey(routes.BuildLargePicturePath, track.LargeName)), - _ => (track.MediumName, BuildKey(routes.BuildMediumPicturePath, track.MediumName)) - }; + var contentType = name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) + ? "image/webp" + : name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) + ? "image/png" + : "image/jpeg"; - if (string.IsNullOrEmpty(name) || key == null) - return Error.NotFound(); - - var contentType = name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) - ? "image/webp" - : name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) - ? "image/png" - : "image/jpeg"; - - return new TrackCoverLocation(storageConfiguration.Bucket, key, contentType); - } - - private static string? BuildKey(Func builder, string? name) - => string.IsNullOrEmpty(name) ? null : builder(name); + return new TrackCoverLocation(storageConfiguration.Bucket, key, contentType); } -} \ No newline at end of file + + private static string? BuildKey(Func builder, string? name) + => string.IsNullOrEmpty(name) ? null : builder(name); +} diff --git a/backend/Core/Musify.Application/Tracks/GetTrackStream.cs b/backend/Core/Musify.Application/Tracks/GetTrackStream.cs index 1135863..a9ceae8 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackStream.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackStream.cs @@ -7,43 +7,42 @@ using Musify.Application.Contracts; using Musify.Application.Services; using Musify.Application.Tracks.Responses; -namespace Musify.Application.Tracks +namespace Musify.Application.Tracks; + + +public record GetTrackStreamQuery(Guid TrackId, long? UserId) + : IQuery>; + +public class GetTrackStreamQueryHandler( + IDatabase database, + TrackStreamIssuer streamIssuer, + PlaybackConfiguration playbackConfiguration, + ILogger logger) + : IQueryHandler> { - - public record GetTrackStreamQuery(Guid TrackId, long? UserId) - : IQuery>; - - public class GetTrackStreamQueryHandler( - IDatabase database, - TrackStreamIssuer streamIssuer, - PlaybackConfiguration playbackConfiguration, - ILogger logger) - : IQueryHandler> + public async ValueTask> Handle(GetTrackStreamQuery request, CancellationToken cancellationToken) { - public async ValueTask> Handle(GetTrackStreamQuery request, CancellationToken cancellationToken) + 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) + .Select(t => new { t.Id, t.Audio }) + .SingleOrDefaultAsync(cancellationToken); + + if (track == null) + return Error.NotFound(); + + if (!track.Audio.IsProcessed) { - 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) - .Select(t => new { t.Id, t.Audio }) - .SingleOrDefaultAsync(cancellationToken); - - if (track == null) - return Error.NotFound(); - - if (!track.Audio.IsProcessed) - { - logger.LogInformation("Stream requested for track {TrackId} but audio is not ready", request.TrackId); - return Error.Conflict(description: "Track audio is not available for streaming yet."); - } - - var response = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken); - - logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId?.ToString() ?? "(anonymous)"); - - return response; + logger.LogInformation("Stream requested for track {TrackId} but audio is not ready", request.TrackId); + return Error.Conflict(description: "Track audio is not available for streaming yet."); } + + var response = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken); + + logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId?.ToString() ?? "(anonymous)"); + + return response; } } diff --git a/backend/Core/Musify.Application/Tracks/GetTracks.cs b/backend/Core/Musify.Application/Tracks/GetTracks.cs index 8174418..8d0c533 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracks.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracks.cs @@ -1,57 +1,55 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; -using Musify.Application.Tracks.Responses; -using X.PagedList; -using X.PagedList.EF; using Musify.Application.Contracts; +using Musify.Application.Tracks.Responses; using Musify.Domain.ValueObjects; +using X.PagedList.EF; -namespace Musify.Application.Tracks +namespace Musify.Application.Tracks; + +public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null) + : IQuery>; + +public class GetTracksQueryHandler(IDatabase database) + : IQueryHandler> { - public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null) - : IQuery>; - - public class GetTracksQueryHandler(IDatabase database) - : IQueryHandler> + public async ValueTask> Handle(GetTracksQuery request, CancellationToken cancellationToken) { - public async ValueTask> Handle(GetTracksQuery request, CancellationToken cancellationToken) + var tracksQuery = database.UserHasTracks + .AsNoTracking() + .Include(ut => ut.Track.Owner) + .Include(ut => ut.Track.Tags) + .AsQueryable(); + + if (!string.IsNullOrEmpty(request.Name)) { - var tracksQuery = database.UserHasTracks - .AsNoTracking() - .Include(ut => ut.Track.Owner) - .Include(ut => ut.Track.Tags) - .AsQueryable(); - - if (!string.IsNullOrEmpty(request.Name)) - { - var normalizedName = request.Name.Trim().ToUpperInvariant(); - tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName)); - } - - if (request.Genre is { } genre) - tracksQuery = tracksQuery.Where(t => t.Track.Tags.Any(tag => tag.Tag == genre)); - - var totalCount = await tracksQuery.CountAsync(cancellationToken); - - var pagedEntities = await tracksQuery - .OrderByDescending(t => t.Track.CreatedAt) - .ThenBy(t => t.Id) - .Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) }) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - - var items = pagedEntities - .Select(x => TrackSearchItemResponse.FromTrack(TrackApplicationResponse.FromEntity(x.Track, x.ListensCount))) - .ToList(); - - return new TracksSearchResponse( - items, - pagedEntities.PageNumber, - pagedEntities.PageSize, - pagedEntities.PageCount, - pagedEntities.TotalItemCount, - pagedEntities.HasPreviousPage, - pagedEntities.HasNextPage); + var normalizedName = request.Name.Trim().ToUpperInvariant(); + tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName)); } + + if (request.Genre is { } genre) + tracksQuery = tracksQuery.Where(t => t.Track.Tags.Any(tag => tag.Tag == genre)); + + var totalCount = await tracksQuery.CountAsync(cancellationToken); + + var pagedEntities = await tracksQuery + .OrderByDescending(t => t.Track.CreatedAt) + .ThenBy(t => t.Id) + .Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) }) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + + var items = pagedEntities + .Select(x => TrackSearchItemResponse.FromTrack(TrackApplicationResponse.FromEntity(x.Track, x.ListensCount))) + .ToList(); + + return new TracksSearchResponse( + items, + pagedEntities.PageNumber, + pagedEntities.PageSize, + pagedEntities.PageCount, + pagedEntities.TotalItemCount, + pagedEntities.HasPreviousPage, + pagedEntities.HasNextPage); } } diff --git a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs index 20a7cd6..edeacf2 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs @@ -1,53 +1,52 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; using X.PagedList; using X.PagedList.EF; -using Musify.Application.Contracts; -namespace Musify.Application.Tracks +namespace Musify.Application.Tracks; + +public record GetTracksByUserIdQuery( + long UserId, + string? Name, + int PageNumber, + int PageSize) + : IQuery>>; + +public class GetTracksByUserIdQueryHandler(IDatabase database) + : IQueryHandler>> { - public record GetTracksByUserIdQuery( - long UserId, - string? Name, - int PageNumber, - int PageSize) - : IQuery>>; - - public class GetTracksByUserIdQueryHandler(IDatabase database) - : IQueryHandler>> + public async ValueTask>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken) { - public async ValueTask>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken) + var pageNumber = request.PageNumber < 1 ? 1 : request.PageNumber; + var pageSize = request.PageSize < 1 ? 10 : request.PageSize; + + var tracksQuery = database.UserHasTracks + .AsNoTracking() + .Include(ut => ut.Track.Owner) + .Include(ut => ut.Track.Tags) + .Where(p => p.UserId == request.UserId); + + if (!string.IsNullOrEmpty(request.Name)) { - var pageNumber = request.PageNumber < 1 ? 1 : request.PageNumber; - var pageSize = request.PageSize < 1 ? 10 : request.PageSize; - - var tracksQuery = database.UserHasTracks - .AsNoTracking() - .Include(ut => ut.Track.Owner) - .Include(ut => ut.Track.Tags) - .Where(p => p.UserId == request.UserId); - - if (!string.IsNullOrEmpty(request.Name)) - { - var normalizedName = request.Name.Trim().ToUpperInvariant(); - tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName)); - } - - var totalCount = await tracksQuery.CountAsync(cancellationToken); - - var pagedEntities = await tracksQuery - .OrderBy(t => t.Track.CreatedAt) - .Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) }) - .ToPagedListAsync(pageNumber, pageSize, totalCount, cancellationToken); - - var pagedTracks = new StaticPagedList( - pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)), - pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); - - return PaginatedResponse.FromPagedList(pagedTracks); + var normalizedName = request.Name.Trim().ToUpperInvariant(); + tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName)); } + + var totalCount = await tracksQuery.CountAsync(cancellationToken); + + var pagedEntities = await tracksQuery + .OrderBy(t => t.Track.CreatedAt) + .Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) }) + .ToPagedListAsync(pageNumber, pageSize, totalCount, cancellationToken); + + var pagedTracks = new StaticPagedList( + pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)), + pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); + + return PaginatedResponse.FromPagedList(pagedTracks); } } diff --git a/backend/Core/Musify.Application/Tracks/ListeningRules.cs b/backend/Core/Musify.Application/Tracks/ListeningRules.cs index 146ef1e..2992575 100644 --- a/backend/Core/Musify.Application/Tracks/ListeningRules.cs +++ b/backend/Core/Musify.Application/Tracks/ListeningRules.cs @@ -1,14 +1,13 @@ -namespace Musify.Application.Tracks -{ - 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; +namespace Musify.Application.Tracks; - public static double CountedThreshold(double durationSeconds) => - Math.Min(CountedThresholdSeconds, durationSeconds * CountedThresholdFraction); - } +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); } diff --git a/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs index c23a410..d3a5907 100644 --- a/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs +++ b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs @@ -3,42 +3,41 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; -namespace Musify.Application.Tracks +namespace Musify.Application.Tracks; + +public record RecordListeningProgressCommand( + long UserId, + Guid ListenId, + double PlayedSeconds) + : ICommand>; + +public class RecordListeningProgressCommandHandler(IDatabase database) + : ICommandHandler> { - public record RecordListeningProgressCommand( - long UserId, - Guid ListenId, - double PlayedSeconds) - : ICommand>; - - public class RecordListeningProgressCommandHandler(IDatabase database) - : ICommandHandler> + public async ValueTask> Handle(RecordListeningProgressCommand request, CancellationToken cancellationToken) { - public async ValueTask> Handle(RecordListeningProgressCommand request, CancellationToken cancellationToken) - { - if (!double.IsFinite(request.PlayedSeconds) || request.PlayedSeconds < 0) - return Error.Validation(description: "playedSeconds must be a non-negative number."); + if (!double.IsFinite(request.PlayedSeconds) || request.PlayedSeconds < 0) + return Error.Validation(description: "playedSeconds must be a non-negative number."); - var listen = await database.ListeningHistories - .Include(l => l.Track) - .SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, 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 Error.NotFound(); + if (listen == null) + return Error.NotFound(); - 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 09ae79b..fa077db 100644 --- a/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs +++ b/backend/Core/Musify.Application/Tracks/RequestTrackUploadUrls.cs @@ -3,141 +3,140 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Configuration; +using Musify.Application.Contracts; using Musify.Application.Services; using Musify.Application.Tracks.Responses; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -using Musify.Application.Contracts; -namespace Musify.Application.Tracks +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( + IDatabase database, + UploadIntentValidator uploadIntentValidator, + IStorageService storageService, + ILogger logger, + ApplicationStorageConfiguration storageConfiguration, + TrackConfiguration trackConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) + : ICommandHandler> { - public record RequestTrackUploadUrlsCommand( - long UserId, - string PictureFileType, - string PictureContentType, - string AudioFileType, - string AudioContentType, - long? ExpectedPictureSizeBytes = null, - long? ExpectedAudioSizeBytes = null) - : ICommand>; - - public class RequestTrackUploadUrlsCommandHandler( - IDatabase database, - UploadIntentValidator uploadIntentValidator, - IStorageService storageService, - ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - TrackConfiguration trackConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) - : ICommandHandler> + public async ValueTask> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken) { - public async ValueTask> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken) + var userExists = await database.Users + .AsNoTracking() + .AnyAsync(u => u.Id == request.UserId, cancellationToken); + if (!userExists) { - var userExists = await database.Users - .AsNoTracking() - .AnyAsync(u => u.Id == request.UserId, cancellationToken); - if (!userExists) + logger.LogWarning("User {UserId} not found", request.UserId); + return Error.NotFound(description: $"User {request.UserId} not found"); + } + + var effectivePictureSize = request.ExpectedPictureSizeBytes + ?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes; + var effectiveAudioSize = request.ExpectedAudioSizeBytes + ?? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes; + + var pictureObjectName = $"{Guid.NewGuid()}.{request.PictureFileType.TrimStart('.').ToLowerInvariant()}"; + var audioObjectName = $"{Guid.NewGuid()}.{request.AudioFileType.TrimStart('.').ToLowerInvariant()}"; + + var tempPictureKey = trackConfiguration.Routes.BuildTempPicturePath( + uploadIntentConfiguration.TemporalRootPrefix, request.UserId, pictureObjectName); + var tempAudioKey = trackConfiguration.Routes.BuildTempAudioPath( + uploadIntentConfiguration.TemporalRootPrefix, request.UserId, audioObjectName); + + try + { + var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds); + + var pictureUploadUrl = await storageService.GetUploadUrlAsync( + storageConfiguration.Bucket, + tempPictureKey, + request.PictureContentType, + expiresIn, + cancellationToken); + + var audioUploadUrl = await storageService.GetUploadUrlAsync( + storageConfiguration.Bucket, + tempAudioKey, + request.AudioContentType, + expiresIn, + cancellationToken); + + var expiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds); + + await using var transaction = await database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, cancellationToken); + + var quotaCheck = await uploadIntentValidator.CheckQuotaAsync( + uploadIntentConfiguration, + request.UserId, effectivePictureSize + effectiveAudioSize, 2, cancellationToken); + if (quotaCheck.IsError) { - logger.LogWarning("User {UserId} not found", request.UserId); - return Error.NotFound(description: $"User {request.UserId} not found"); + logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId); + return quotaCheck.Errors; } - var effectivePictureSize = request.ExpectedPictureSizeBytes - ?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes; - var effectiveAudioSize = request.ExpectedAudioSizeBytes - ?? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes; - - var pictureObjectName = $"{Guid.NewGuid()}.{request.PictureFileType.TrimStart('.').ToLowerInvariant()}"; - var audioObjectName = $"{Guid.NewGuid()}.{request.AudioFileType.TrimStart('.').ToLowerInvariant()}"; - - var tempPictureKey = trackConfiguration.Routes.BuildTempPicturePath( - uploadIntentConfiguration.TemporalRootPrefix, request.UserId, pictureObjectName); - var tempAudioKey = trackConfiguration.Routes.BuildTempAudioPath( - uploadIntentConfiguration.TemporalRootPrefix, request.UserId, audioObjectName); - - try + var pictureIntent = new UploadIntent { - var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds); + UserId = request.UserId, + Bucket = storageConfiguration.Bucket, + Key = tempPictureKey, + ObjectName = pictureObjectName, + ContentType = request.PictureContentType, + ExpectedSizeBytes = effectivePictureSize, + Purpose = UploadIntentPurpose.TrackPicture, + ExpiresAt = expiresAt, + }; - var pictureUploadUrl = await storageService.GetUploadUrlAsync( - storageConfiguration.Bucket, - tempPictureKey, - request.PictureContentType, - expiresIn, - cancellationToken); - - var audioUploadUrl = await storageService.GetUploadUrlAsync( - storageConfiguration.Bucket, - tempAudioKey, - request.AudioContentType, - expiresIn, - cancellationToken); - - var expiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds); - - await using var transaction = await database.BeginTransactionAsync( - System.Data.IsolationLevel.Serializable, cancellationToken); - - var quotaCheck = await uploadIntentValidator.CheckQuotaAsync( - uploadIntentConfiguration, - request.UserId, effectivePictureSize + effectiveAudioSize, 2, cancellationToken); - if (quotaCheck.IsError) - { - logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId); - return quotaCheck.Errors; - } - - var pictureIntent = new UploadIntent - { - UserId = request.UserId, - Bucket = storageConfiguration.Bucket, - Key = tempPictureKey, - ObjectName = pictureObjectName, - ContentType = request.PictureContentType, - ExpectedSizeBytes = effectivePictureSize, - Purpose = UploadIntentPurpose.TrackPicture, - ExpiresAt = expiresAt, - }; - - var audioIntent = new UploadIntent - { - UserId = request.UserId, - Bucket = storageConfiguration.Bucket, - Key = tempAudioKey, - ObjectName = audioObjectName, - ContentType = request.AudioContentType, - ExpectedSizeBytes = effectiveAudioSize, - Purpose = UploadIntentPurpose.TrackAudio, - ExpiresAt = expiresAt, - }; - - await database.UploadIntents.AddAsync(pictureIntent, cancellationToken); - await database.UploadIntents.AddAsync(audioIntent, cancellationToken); - await database.SaveChangesAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); - - logger.LogInformation("Issued upload intents {PictureIntentId}/{AudioIntentId} for user {UserId} (TrackPicture/TrackAudio)", - pictureIntent.Id, audioIntent.Id, request.UserId); - - return new TrackUploadUrlsResponse( - pictureIntent.Id, - audioIntent.Id, - storageConfiguration.Bucket, - tempPictureKey, - pictureObjectName, - request.PictureContentType, - pictureUploadUrl, - tempAudioKey, - audioObjectName, - request.AudioContentType, - audioUploadUrl, - uploadIntentConfiguration.UploadUrlExpiresInSeconds); - } - catch (Exception exception) + var audioIntent = new UploadIntent { - logger.LogError(exception, "Failed to generate track upload URLs for user {UserId}", request.UserId); - return Error.Failure(description: "Failed to generate upload URLs"); - } + UserId = request.UserId, + Bucket = storageConfiguration.Bucket, + Key = tempAudioKey, + ObjectName = audioObjectName, + ContentType = request.AudioContentType, + ExpectedSizeBytes = effectiveAudioSize, + Purpose = UploadIntentPurpose.TrackAudio, + ExpiresAt = expiresAt, + }; + + await database.UploadIntents.AddAsync(pictureIntent, cancellationToken); + await database.UploadIntents.AddAsync(audioIntent, cancellationToken); + await database.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + + logger.LogInformation("Issued upload intents {PictureIntentId}/{AudioIntentId} for user {UserId} (TrackPicture/TrackAudio)", + pictureIntent.Id, audioIntent.Id, request.UserId); + + return new TrackUploadUrlsResponse( + pictureIntent.Id, + audioIntent.Id, + storageConfiguration.Bucket, + tempPictureKey, + pictureObjectName, + request.PictureContentType, + pictureUploadUrl, + tempAudioKey, + audioObjectName, + request.AudioContentType, + audioUploadUrl, + uploadIntentConfiguration.UploadUrlExpiresInSeconds); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to generate track upload URLs for user {UserId}", request.UserId); + return Error.Failure(description: "Failed to generate upload URLs"); } } } diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs index 2748ded..12a76de 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackApplicationResponse.cs @@ -1,35 +1,34 @@ +using System.Text.Json.Serialization; using Musify.Application.Serialization; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -using System.Text.Json.Serialization; -namespace Musify.Application.Tracks.Responses +namespace Musify.Application.Tracks.Responses; + +public record TrackApplicationResponse( + Guid Id, + string Title, + string? Artist, + ProcessingStatus AudioStatus, + double Duration, + int ListensCount, + DateTime CreatedAt, + DateTime UpdatedAt, + [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, + IReadOnlyCollection Tags, + bool IsExplicit) { - public record TrackApplicationResponse( - Guid Id, - string Title, - string? Artist, - ProcessingStatus AudioStatus, - double Duration, - int ListensCount, - DateTime CreatedAt, - DateTime UpdatedAt, - [property: JsonConverter(typeof(LongAsStringConverter))] 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); - } + 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/TrackSearchItemResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs index 1477ec4..9e7af80 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackSearchItemResponse.cs @@ -1,7 +1,6 @@ -namespace Musify.Application.Tracks.Responses +namespace Musify.Application.Tracks.Responses; + +public record TrackSearchItemResponse(TrackApplicationResponse Track) { - public record TrackSearchItemResponse(TrackApplicationResponse Track) - { - public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) => new(track); - } + public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) => new(track); } diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackStreamResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackStreamResponse.cs index 024edf7..146e1ac 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackStreamResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackStreamResponse.cs @@ -1,8 +1,7 @@ -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 8e1f5fb..e5b6106 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackUploadUrlsResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackUploadUrlsResponse.cs @@ -1,16 +1,15 @@ -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/Responses/TracksSearchResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs index 4fddfb6..c454450 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TracksSearchResponse.cs @@ -1,12 +1,11 @@ -namespace Musify.Application.Tracks.Responses -{ +namespace Musify.Application.Tracks.Responses; - public record TracksSearchResponse( - IReadOnlyList Items, - int PageNumber, - int PageSize, - int PageCount, - int TotalItemCount, - bool HasPreviousPage, - bool HasNextPage); -} + +public record TracksSearchResponse( + IReadOnlyList Items, + int PageNumber, + int PageSize, + int PageCount, + int TotalItemCount, + bool HasPreviousPage, + bool HasNextPage); diff --git a/backend/Core/Musify.Application/Users/CreateUser.cs b/backend/Core/Musify.Application/Users/CreateUser.cs index ad19221..2eaa279 100644 --- a/backend/Core/Musify.Application/Users/CreateUser.cs +++ b/backend/Core/Musify.Application/Users/CreateUser.cs @@ -6,54 +6,53 @@ using Musify.Application.Contracts; using Musify.Application.Users.Responses; using Musify.Domain.Entities; -namespace Musify.Application.Users +namespace Musify.Application.Users; + +public record CreateUserCommand(long Id, + string Name, + string? FirstName, + string? SecondName) + : ICommand> { - public record CreateUserCommand(long Id, - string Name, - string? FirstName, - string? SecondName) - : ICommand> + public static User ToEntity(CreateUserCommand command) { - public static User ToEntity(CreateUserCommand command) + return new User { - return new User - { - Id = command.Id, - Name = command.Name, - NormalizedName = command.Name.ToUpperInvariant(), - FirstName = command.FirstName, - SecondName = command.SecondName - }; - } + Id = command.Id, + Name = command.Name, + NormalizedName = command.Name.ToUpperInvariant(), + FirstName = command.FirstName, + SecondName = command.SecondName + }; } +} - public class CreateUserCommandHandler(IDatabase database, ILogger logger) - : ICommandHandler> +public class CreateUserCommandHandler(IDatabase database, ILogger logger) + : ICommandHandler> +{ + public async ValueTask> Handle(CreateUserCommand request, CancellationToken cancellationToken) { - public async ValueTask> Handle(CreateUserCommand request, CancellationToken cancellationToken) + var userExists = await database.Users.AnyAsync(u => u.Id == request.Id, cancellationToken); + if (userExists) { - var userExists = await database.Users.AnyAsync(u => u.Id == request.Id, cancellationToken); - if (userExists) - { - logger.LogInformation("User {UserId} already exists", request.Id); - return Error.Conflict(description: $"User {request.Id} already exists"); - } + logger.LogInformation("User {UserId} already exists", request.Id); + return Error.Conflict(description: $"User {request.Id} already exists"); + } - var newUser = CreateUserCommand.ToEntity(request); + var newUser = CreateUserCommand.ToEntity(request); - try - { - await database.Users.AddAsync(newUser, cancellationToken); - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to create user {UserId}", request.Id); - return Error.Failure(description: $"Failed to create user {request.Id}"); - } + try + { + await database.Users.AddAsync(newUser, cancellationToken); + await database.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to create user {UserId}", request.Id); + return Error.Failure(description: $"Failed to create user {request.Id}"); + } - logger.LogInformation("Created user {UserId}", newUser.Id); - return UserApplicationResponse.FromEntity(newUser); - } + logger.LogInformation("Created user {UserId}", newUser.Id); + return UserApplicationResponse.FromEntity(newUser); } } diff --git a/backend/Core/Musify.Application/Users/FollowUser.cs b/backend/Core/Musify.Application/Users/FollowUser.cs index c4d6541..767d8f3 100644 --- a/backend/Core/Musify.Application/Users/FollowUser.cs +++ b/backend/Core/Musify.Application/Users/FollowUser.cs @@ -5,46 +5,45 @@ using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Domain.Entities; -namespace Musify.Application.Users +namespace Musify.Application.Users; + +public record FollowUserCommand(long FollowerId, long FollowedId) : ICommand>; + +public class FollowUserCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> { - public record FollowUserCommand(long FollowerId, long FollowedId) : ICommand>; - - public class FollowUserCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> + public async ValueTask> Handle(FollowUserCommand request, CancellationToken cancellationToken) { - public async ValueTask> Handle(FollowUserCommand request, CancellationToken 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) { - 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) - { - logger.LogInformation("User {FollowedId} not found", request.FollowedId); - return Error.NotFound(description: $"User {request.FollowedId} not found"); - } - - var alreadyFollowing = await database.UserFollows - .AsNoTracking() - .AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken); - if (alreadyFollowing) - return new Success(); - - await database.UserFollows.AddAsync(new UserFollow - { - FollowerId = request.FollowerId, - FollowedId = request.FollowedId - }, cancellationToken); - - await database.SaveChangesAsync(cancellationToken); - - logger.LogInformation("User {FollowerId} followed user {FollowedId}", request.FollowerId, request.FollowedId); - - return new Success(); + logger.LogInformation("User {FollowedId} not found", request.FollowedId); + return Error.NotFound(description: $"User {request.FollowedId} not found"); } + + var alreadyFollowing = await database.UserFollows + .AsNoTracking() + .AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken); + if (alreadyFollowing) + return new Success(); + + await database.UserFollows.AddAsync(new UserFollow + { + FollowerId = request.FollowerId, + FollowedId = request.FollowedId + }, cancellationToken); + + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("User {FollowerId} followed user {FollowedId}", request.FollowerId, request.FollowedId); + + return new Success(); } } diff --git a/backend/Core/Musify.Application/Users/FollowVisibility.cs b/backend/Core/Musify.Application/Users/FollowVisibility.cs index b3c45a8..573e1a3 100644 --- a/backend/Core/Musify.Application/Users/FollowVisibility.cs +++ b/backend/Core/Musify.Application/Users/FollowVisibility.cs @@ -1,30 +1,29 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; -namespace Musify.Application.Users +namespace Musify.Application.Users; + +public static class FollowVisibility { - public static class FollowVisibility + public static async Task CanViewFollowersAsync( + IDatabase database, + long? viewerId, + long userId, + CancellationToken cancellationToken) { - public static async Task CanViewFollowersAsync( - IDatabase database, - long? viewerId, - long userId, - CancellationToken cancellationToken) - { - if (viewerId is not { } viewer) - return false; + 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 d097b85..56428d8 100644 --- a/backend/Core/Musify.Application/Users/GetListeningStats.cs +++ b/backend/Core/Musify.Application/Users/GetListeningStats.cs @@ -2,50 +2,49 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; -namespace Musify.Application.Users +namespace Musify.Application.Users; + +public record GetListeningStatsQuery(long UserId) : IQuery; + +public record ListeningStatsResponse(int TracksThisWeek, int SecondsThisWeek, int StreakDays); + +public class GetListeningStatsQueryHandler(IDatabase database) + : IQueryHandler { - public record GetListeningStatsQuery(long UserId) : IQuery; - - public record ListeningStatsResponse(int TracksThisWeek, int SecondsThisWeek, int StreakDays); - - public class GetListeningStatsQueryHandler(IDatabase database) - : IQueryHandler + public async ValueTask Handle(GetListeningStatsQuery request, CancellationToken cancellationToken) { - public async ValueTask Handle(GetListeningStatsQuery request, CancellationToken cancellationToken) + 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; + while (listenedDateSet.Contains(day)) { - 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; - while (listenedDateSet.Contains(day)) - { - streakDays++; - day = day.AddDays(-1); - } - - return new ListeningStatsResponse(tracksThisWeek, (int)Math.Round(secondsThisWeek), streakDays); + 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 92d651b..989cb04 100644 --- a/backend/Core/Musify.Application/Users/GetUserById.cs +++ b/backend/Core/Musify.Application/Users/GetUserById.cs @@ -4,26 +4,25 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Users.Responses; -namespace Musify.Application.Users +namespace Musify.Application.Users; + +public record GetUserByIdQuery(long Id) + : IQuery>; + +public class GetUserByIdQueryHandler(IDatabase database) + : IQueryHandler> { - public record GetUserByIdQuery(long Id) - : IQuery>; - - public class GetUserByIdQueryHandler(IDatabase database) - : IQueryHandler> + public async ValueTask> Handle(GetUserByIdQuery request, CancellationToken cancellationToken) { - 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); + var user = await database.Users + .AsNoTracking() + .Where(u => u.Id == request.Id) + .Select(u => UserApplicationResponse.FromEntity(u)) + .SingleOrDefaultAsync(cancellationToken); - if (user == null) - return Error.NotFound(); + if (user == null) + return Error.NotFound(); - return user; - } + return user; } } diff --git a/backend/Core/Musify.Application/Users/GetUserFollowers.cs b/backend/Core/Musify.Application/Users/GetUserFollowers.cs index e5b0d9e..8f280e1 100644 --- a/backend/Core/Musify.Application/Users/GetUserFollowers.cs +++ b/backend/Core/Musify.Application/Users/GetUserFollowers.cs @@ -6,41 +6,40 @@ using Musify.Application.Shared; using Musify.Application.Users.Responses; using X.PagedList.EF; -namespace Musify.Application.Users +namespace Musify.Application.Users; + +public record GetUserFollowersQuery(long? ViewerId, long UserId, int PageNumber, int PageSize) + : IQuery>>; + +public class GetUserFollowersQueryHandler(IDatabase database) + : IQueryHandler>> { - public record GetUserFollowersQuery(long? ViewerId, long UserId, int PageNumber, int PageSize) - : IQuery>>; - - public class GetUserFollowersQueryHandler(IDatabase database) - : IQueryHandler>> + public async ValueTask>> Handle(GetUserFollowersQuery request, CancellationToken cancellationToken) { - public async ValueTask>> Handle(GetUserFollowersQuery request, CancellationToken cancellationToken) - { - if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken)) - return Error.NotFound(); + if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken)) + return Error.NotFound(); - if (!await FollowVisibility.CanViewFollowersAsync(database, request.ViewerId, request.UserId, cancellationToken)) - return Error.Forbidden(); + if (!await FollowVisibility.CanViewFollowersAsync(database, request.ViewerId, request.UserId, cancellationToken)) + return Error.Forbidden(); - var followsQuery = database.UserFollows - .AsNoTracking() - .Where(f => f.FollowedId == request.UserId); + var followsQuery = database.UserFollows + .AsNoTracking() + .Where(f => f.FollowedId == request.UserId); - var totalCount = await followsQuery.CountAsync(cancellationToken); - var viewerId = request.ViewerId; + var totalCount = await followsQuery.CountAsync(cancellationToken); + var viewerId = request.ViewerId; - var page = await followsQuery - .OrderByDescending(f => f.CreatedAt) - .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))) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + var page = await followsQuery + .OrderByDescending(f => f.CreatedAt) + .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))) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - return PaginatedResponse.FromPagedList(page); - } + return PaginatedResponse.FromPagedList(page); } } diff --git a/backend/Core/Musify.Application/Users/GetUserFollowing.cs b/backend/Core/Musify.Application/Users/GetUserFollowing.cs index baa9221..89279fa 100644 --- a/backend/Core/Musify.Application/Users/GetUserFollowing.cs +++ b/backend/Core/Musify.Application/Users/GetUserFollowing.cs @@ -6,38 +6,37 @@ using Musify.Application.Shared; using Musify.Application.Users.Responses; using X.PagedList.EF; -namespace Musify.Application.Users +namespace Musify.Application.Users; + +public record GetUserFollowingQuery(long? ViewerId, long UserId, int PageNumber, int PageSize) + : IQuery>>; + +public class GetUserFollowingQueryHandler(IDatabase database) + : IQueryHandler>> { - public record GetUserFollowingQuery(long? ViewerId, long UserId, int PageNumber, int PageSize) - : IQuery>>; - - public class GetUserFollowingQueryHandler(IDatabase database) - : IQueryHandler>> + public async ValueTask>> Handle(GetUserFollowingQuery request, CancellationToken cancellationToken) { - public async ValueTask>> Handle(GetUserFollowingQuery request, CancellationToken cancellationToken) - { - if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken)) - return Error.NotFound(); + if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken)) + return Error.NotFound(); - var followsQuery = database.UserFollows - .AsNoTracking() - .Where(f => f.FollowerId == request.UserId); + var followsQuery = database.UserFollows + .AsNoTracking() + .Where(f => f.FollowerId == request.UserId); - var totalCount = await followsQuery.CountAsync(cancellationToken); - var viewerId = request.ViewerId; + var totalCount = await followsQuery.CountAsync(cancellationToken); + var viewerId = request.ViewerId; - var page = await followsQuery - .OrderByDescending(f => f.CreatedAt) - .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))) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + var page = await followsQuery + .OrderByDescending(f => f.CreatedAt) + .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))) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - return PaginatedResponse.FromPagedList(page); - } + return PaginatedResponse.FromPagedList(page); } } diff --git a/backend/Core/Musify.Application/Users/GetUserProfile.cs b/backend/Core/Musify.Application/Users/GetUserProfile.cs index 7066d90..88039db 100644 --- a/backend/Core/Musify.Application/Users/GetUserProfile.cs +++ b/backend/Core/Musify.Application/Users/GetUserProfile.cs @@ -5,71 +5,70 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Serialization; -namespace Musify.Application.Users +namespace Musify.Application.Users; + +public record GetUserProfileQuery(long UserId, long? ViewerId = null) : IQuery>; + +public record UserProfileResponse( + [property: JsonConverter(typeof(LongAsStringConverter))] long Id, + string Name, + string? FirstName, + string? SecondName, + string? ProfilePictureUrl, + DateTime CreatedAt, + int FollowersCount, + int FollowingCount, + bool IsFollowing, + bool CanViewFollowers); + +public class GetUserProfileQueryHandler(IDatabase database) + : IQueryHandler> { - public record GetUserProfileQuery(long UserId, long? ViewerId = null) : IQuery>; - - public record UserProfileResponse( - [property: JsonConverter(typeof(LongAsStringConverter))] long Id, - string Name, - string? FirstName, - string? SecondName, - string? ProfilePictureUrl, - DateTime CreatedAt, - int FollowersCount, - int FollowingCount, - bool IsFollowing, - bool CanViewFollowers); - - public class GetUserProfileQueryHandler(IDatabase database) - : IQueryHandler> + public async ValueTask> Handle(GetUserProfileQuery request, CancellationToken cancellationToken) { - public async ValueTask> Handle(GetUserProfileQuery request, CancellationToken cancellationToken) - { - var user = await database.Users + 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 Error.NotFound(); + + 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() - .Where(u => u.Id == request.UserId) - .Select(u => new - { - u.Id, - u.Name, - u.FirstName, - u.SecondName, - u.ProfilePictureUrl, - u.CreatedAt - }) - .SingleOrDefaultAsync(cancellationToken); + .AnyAsync(f => f.FollowerId == viewerId && f.FollowedId == request.UserId, cancellationToken); - if (user == null) - return Error.NotFound(); + var canViewFollowers = await FollowVisibility.CanViewFollowersAsync( + database, request.ViewerId, request.UserId, cancellationToken); - 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); - } + 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 7023c84..2d17ebf 100644 --- a/backend/Core/Musify.Application/Users/GetUsers.cs +++ b/backend/Core/Musify.Application/Users/GetUsers.cs @@ -1,45 +1,44 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Users.Responses; using X.PagedList.EF; -using Musify.Application.Contracts; -namespace Musify.Application.Users +namespace Musify.Application.Users; + +public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch, long? ViewerId = null) + : IQuery>>; + +public class GetUsersQueryHandler(IDatabase database) + : IQueryHandler>> { - public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch, long? ViewerId = null) - : IQuery>>; - - public class GetUsersQueryHandler(IDatabase database) - : IQueryHandler>> + public async ValueTask>> Handle(GetUsersQuery request, CancellationToken cancellationToken) { - public async ValueTask>> Handle(GetUsersQuery request, CancellationToken cancellationToken) + var usersQuery = database.Users + .AsNoTracking() + .AsQueryable(); + + if (!string.IsNullOrEmpty(request.UsernameSearch)) { - var usersQuery = database.Users - .AsNoTracking() - .AsQueryable(); - - if (!string.IsNullOrEmpty(request.UsernameSearch)) - { - var normalizedUsername = request.UsernameSearch.Trim().ToUpperInvariant(); - usersQuery = usersQuery.Where(u => u.NormalizedName.Contains(normalizedUsername)); - } - - var totalCount = await usersQuery.CountAsync(cancellationToken); - var viewerId = request.ViewerId; - var pagedUsers = await usersQuery - .OrderBy(u => u.Name) - .Select(u => new UserSummaryResponse( - u.Id, - u.Name, - u.FirstName, - u.SecondName, - u.ProfilePictureUrl, - viewerId != null && database.UserFollows.Any(f => f.FollowerId == viewerId && f.FollowedId == u.Id))) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - - return PaginatedResponse.FromPagedList(pagedUsers); + var normalizedUsername = request.UsernameSearch.Trim().ToUpperInvariant(); + usersQuery = usersQuery.Where(u => u.NormalizedName.Contains(normalizedUsername)); } + + var totalCount = await usersQuery.CountAsync(cancellationToken); + var viewerId = request.ViewerId; + var pagedUsers = await usersQuery + .OrderBy(u => u.Name) + .Select(u => new UserSummaryResponse( + u.Id, + u.Name, + u.FirstName, + u.SecondName, + u.ProfilePictureUrl, + viewerId != null && database.UserFollows.Any(f => f.FollowerId == viewerId && f.FollowedId == u.Id))) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + + return PaginatedResponse.FromPagedList(pagedUsers); } } diff --git a/backend/Core/Musify.Application/Users/IsFollowingUser.cs b/backend/Core/Musify.Application/Users/IsFollowingUser.cs index a8e9a2a..44b9ca9 100644 --- a/backend/Core/Musify.Application/Users/IsFollowingUser.cs +++ b/backend/Core/Musify.Application/Users/IsFollowingUser.cs @@ -2,16 +2,15 @@ using Mediator; using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; -namespace Musify.Application.Users -{ - public record IsFollowingUserQuery(long FollowerId, long FollowedId) : IQuery; +namespace Musify.Application.Users; - 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)); - } +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/UserApplicationResponse.cs b/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs index 9bd442f..d0d8840 100644 --- a/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs +++ b/backend/Core/Musify.Application/Users/Responses/UserApplicationResponse.cs @@ -1,28 +1,27 @@ -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; using Musify.Application.Serialization; using Musify.Domain.Entities; -namespace Musify.Application.Users.Responses +namespace Musify.Application.Users.Responses; + +public record UserApplicationResponse( + [property: JsonConverter(typeof(LongAsStringConverter))] long Id, + string Name, + string? FirstName, + string? SecondName, + string? ProfilePictureUrl, + DateTime CreatedAt, + DateTime UpdatedAt) { - public record UserApplicationResponse( - [property: JsonConverter(typeof(LongAsStringConverter))] long Id, - string Name, - string? FirstName, - string? SecondName, - string? ProfilePictureUrl, - DateTime CreatedAt, - DateTime UpdatedAt) + public static UserApplicationResponse FromEntity(User user) { - public static UserApplicationResponse FromEntity(User user) - { - return new UserApplicationResponse( - user.Id, - user.Name, - user.FirstName, - user.SecondName, - user.ProfilePictureUrl, - user.CreatedAt, - user.UpdatedAt); - } + return new UserApplicationResponse( + user.Id, + user.Name, + user.FirstName, + user.SecondName, + user.ProfilePictureUrl, + user.CreatedAt, + user.UpdatedAt); } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs b/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs index 6d0af7f..23eedf3 100644 --- a/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs +++ b/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs @@ -1,13 +1,12 @@ using System.Text.Json.Serialization; using Musify.Application.Serialization; -namespace Musify.Application.Users.Responses -{ - public record UserSummaryResponse( - [property: JsonConverter(typeof(LongAsStringConverter))] long Id, - string Name, - string? FirstName, - string? SecondName, - string? ProfilePictureUrl, - bool IsFollowedByViewer); -} +namespace Musify.Application.Users.Responses; + +public record UserSummaryResponse( + [property: JsonConverter(typeof(LongAsStringConverter))] 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 58056ba..2d94958 100644 --- a/backend/Core/Musify.Application/Users/SyncUser.cs +++ b/backend/Core/Musify.Application/Users/SyncUser.cs @@ -5,53 +5,52 @@ using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Domain.Entities; -namespace Musify.Application.Users +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> { - public record SyncUserCommand( - long Id, - string Name, - string? FirstName, - string? SecondName, - string? ProfilePictureUrl) - : ICommand>; - - public class SyncUserCommandHandler(IDatabase database, ILogger logger) - : ICommandHandler> + public async ValueTask> Handle(SyncUserCommand request, CancellationToken cancellationToken) { - public async ValueTask> Handle(SyncUserCommand request, CancellationToken cancellationToken) + var user = await database.Users + .SingleOrDefaultAsync(u => u.Id == request.Id, cancellationToken); + + if (user == null) { - var user = await database.Users - .SingleOrDefaultAsync(u => u.Id == request.Id, cancellationToken); - - if (user == null) + await database.Users.AddAsync(new User { - await database.Users.AddAsync(new User - { - Id = request.Id, - Name = request.Name, - NormalizedName = request.Name.ToUpperInvariant(), - FirstName = request.FirstName, - SecondName = request.SecondName, - ProfilePictureUrl = request.ProfilePictureUrl - }, cancellationToken); + Id = request.Id, + Name = request.Name, + NormalizedName = request.Name.ToUpperInvariant(), + FirstName = request.FirstName, + SecondName = request.SecondName, + ProfilePictureUrl = request.ProfilePictureUrl + }, cancellationToken); - await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Provisioned user {UserId}", request.Id); - - return new Success(); - } - - user.Name = request.Name; - user.NormalizedName = request.Name.ToUpperInvariant(); - 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); + await database.SaveChangesAsync(cancellationToken); + logger.LogInformation("Provisioned user {UserId}", request.Id); return new Success(); } + + user.Name = request.Name; + user.NormalizedName = request.Name.ToUpperInvariant(); + 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 new Success(); } } diff --git a/backend/Core/Musify.Application/Users/UnfollowUser.cs b/backend/Core/Musify.Application/Users/UnfollowUser.cs index 1009d94..432d3b2 100644 --- a/backend/Core/Musify.Application/Users/UnfollowUser.cs +++ b/backend/Core/Musify.Application/Users/UnfollowUser.cs @@ -4,29 +4,28 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Musify.Application.Contracts; -namespace Musify.Application.Users +namespace Musify.Application.Users; + +public record UnfollowUserCommand(long FollowerId, long FollowedId) : ICommand>; + +public class UnfollowUserCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler> { - public record UnfollowUserCommand(long FollowerId, long FollowedId) : ICommand>; - - public class UnfollowUserCommandHandler( - IDatabase database, - ILogger logger) - : ICommandHandler> + public async ValueTask> Handle(UnfollowUserCommand request, CancellationToken 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 new Success(); - - database.UserFollows.Remove(follow); - await database.SaveChangesAsync(cancellationToken); - - logger.LogInformation("User {FollowerId} unfollowed user {FollowedId}", request.FollowerId, request.FollowedId); + var follow = await database.UserFollows + .SingleOrDefaultAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken); + if (follow == null) return new Success(); - } + + database.UserFollows.Remove(follow); + await database.SaveChangesAsync(cancellationToken); + + logger.LogInformation("User {FollowerId} unfollowed user {FollowedId}", request.FollowerId, request.FollowedId); + + return new Success(); } } diff --git a/backend/Core/Musify.Domain/Abstractions/IAuditable.cs b/backend/Core/Musify.Domain/Abstractions/IAuditable.cs index 4815e2c..b837a31 100644 --- a/backend/Core/Musify.Domain/Abstractions/IAuditable.cs +++ b/backend/Core/Musify.Domain/Abstractions/IAuditable.cs @@ -1,9 +1,8 @@ -namespace Musify.Domain.Abstractions -{ - public interface IAuditable - { - DateTime CreatedAt { get; set; } +namespace Musify.Domain.Abstractions; - DateTime UpdatedAt { get; set; } - } +public interface IAuditable +{ + public DateTime CreatedAt { get; set; } + + public DateTime UpdatedAt { get; set; } } diff --git a/backend/Core/Musify.Domain/Entities/Album.cs b/backend/Core/Musify.Domain/Entities/Album.cs index bf3f866..31c5d46 100644 --- a/backend/Core/Musify.Domain/Entities/Album.cs +++ b/backend/Core/Musify.Domain/Entities/Album.cs @@ -3,44 +3,43 @@ using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; using Musify.Domain.ValueObjects; -namespace Musify.Domain.Entities -{ +namespace Musify.Domain.Entities; + #pragma warning disable CS8618 - public class Album : IAuditable - { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); +public class Album : IAuditable +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - [Required] - [MaxLength(200)] - public required string Title { get; set; } + [Required] + [MaxLength(200)] + public required string Title { get; set; } - [Required] - [MaxLength(200)] - public required string NormalizedTitle { get; set; } + [Required] + [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 AlbumPictures? Pictures { get; set; } + public AlbumPictures? Pictures { get; set; } - [Required] - public required long OwnerUserId { get; set; } + [Required] + public required long OwnerUserId { get; set; } - [ForeignKey(nameof(OwnerUserId))] - public User Owner { get; set; } + [ForeignKey(nameof(OwnerUserId))] + public User Owner { get; set; } - [Required] - public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; + [Required] + public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; - [Required] - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - [Required] - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + [Required] + 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 ed0bcae..8ab9ad4 100644 --- a/backend/Core/Musify.Domain/Entities/AlbumHasTrack.cs +++ b/backend/Core/Musify.Domain/Entities/AlbumHasTrack.cs @@ -1,30 +1,29 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace Musify.Domain.Entities -{ +namespace Musify.Domain.Entities; + #pragma warning disable CS8618 - [Table("AlbumHasTrack")] - public class AlbumHasTrack - { - public Guid Id { get; set; } = Guid.NewGuid(); +[Table("AlbumHasTrack")] +public class AlbumHasTrack +{ + public Guid Id { get; set; } = Guid.NewGuid(); - [Required] - public required Guid AlbumId { get; set; } + [Required] + public required Guid AlbumId { get; set; } - [Required] - public required Guid TrackId { get; set; } + [Required] + public required Guid TrackId { get; set; } - [Required] - public int TrackNumber { get; set; } = 1; + [Required] + public int TrackNumber { get; set; } = 1; - [Required] - public DateTime AddedAt { get; set; } = DateTime.UtcNow; + [Required] + public DateTime AddedAt { get; set; } = DateTime.UtcNow; - [ForeignKey(nameof(AlbumId))] - public Album Album { get; set; } + [ForeignKey(nameof(AlbumId))] + public Album Album { get; set; } - [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } - } + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } } diff --git a/backend/Core/Musify.Domain/Entities/ListeningHistory.cs b/backend/Core/Musify.Domain/Entities/ListeningHistory.cs index 0439cb4..f1e0e5c 100644 --- a/backend/Core/Musify.Domain/Entities/ListeningHistory.cs +++ b/backend/Core/Musify.Domain/Entities/ListeningHistory.cs @@ -1,34 +1,33 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace Musify.Domain.Entities -{ +namespace Musify.Domain.Entities; + #pragma warning disable CS8618 - [Table("ListeningHistory")] - public class ListeningHistory - { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); +[Table("ListeningHistory")] +public class ListeningHistory +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - [Required] - public required long UserId { get; set; } + [Required] + public required long UserId { get; set; } - [Required] - public required Guid TrackId { get; set; } + [Required] + public required Guid TrackId { get; set; } - [Required] - public DateTime ListenedAt { get; set; } = DateTime.UtcNow; + [Required] + 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; } + [ForeignKey(nameof(UserId))] + public User User { get; set; } - [ForeignKey(nameof(TrackId)) ] - public Track Track { get; set; } - } -} \ No newline at end of file + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } +} diff --git a/backend/Core/Musify.Domain/Entities/Mix.cs b/backend/Core/Musify.Domain/Entities/Mix.cs index 3d115d4..f2b44de 100644 --- a/backend/Core/Musify.Domain/Entities/Mix.cs +++ b/backend/Core/Musify.Domain/Entities/Mix.cs @@ -3,37 +3,36 @@ using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; -namespace Musify.Domain.Entities -{ +namespace Musify.Domain.Entities; + #pragma warning disable CS8618 - [Table("Mixes")] - public class Mix : IAuditable - { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); +[Table("Mixes")] +public class Mix : IAuditable +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - [Required] - public required long UserId { get; set; } + [Required] + public required long UserId { get; set; } - [Required] - [MaxLength(100)] - public required string Title { get; set; } + [Required] + [MaxLength(100)] + public required string Title { get; set; } - [MaxLength(256)] - public string? Subtitle { get; set; } + [MaxLength(256)] + public string? Subtitle { get; set; } - [Required] - public int Position { get; set; } = 0; + [Required] + public int Position { get; set; } = 0; - [Required] - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - [Required] - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + [Required] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - [ForeignKey(nameof(UserId))] - public User User { get; set; } + [ForeignKey(nameof(UserId))] + public User User { get; set; } - 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 2269e2d..35bac13 100644 --- a/backend/Core/Musify.Domain/Entities/MixItem.cs +++ b/backend/Core/Musify.Domain/Entities/MixItem.cs @@ -1,28 +1,27 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace Musify.Domain.Entities -{ +namespace Musify.Domain.Entities; + #pragma warning disable CS8618 - [Table("MixItems")] - public class MixItem - { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); +[Table("MixItems")] +public class MixItem +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - [Required] - public required Guid MixId { get; set; } + [Required] + public required Guid MixId { get; set; } - [Required] - public int Position { get; set; } = 0; + [Required] + public int Position { get; set; } = 0; - [Required] - public required Guid TrackId { get; set; } + [Required] + public required Guid TrackId { get; set; } - [ForeignKey(nameof(MixId))] - public Mix Mix { get; set; } + [ForeignKey(nameof(MixId))] + public Mix Mix { get; set; } - [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } - } + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } } diff --git a/backend/Core/Musify.Domain/Entities/PlayList.cs b/backend/Core/Musify.Domain/Entities/PlayList.cs index 61ba628..0812771 100644 --- a/backend/Core/Musify.Domain/Entities/PlayList.cs +++ b/backend/Core/Musify.Domain/Entities/PlayList.cs @@ -4,46 +4,45 @@ using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; using Musify.Domain.ValueObjects; -namespace Musify.Domain.Entities -{ +namespace Musify.Domain.Entities; + #pragma warning disable CS8618 - [Table("PlayLists")] - public class PlayList : IAuditable - { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); +[Table("PlayLists")] +public class PlayList : IAuditable +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - [Required] - public required long UserId { get; set; } + [Required] + public required long UserId { get; set; } - [Required] - [MaxLength(50)] - public required string Name { get; set; } + [Required] + [MaxLength(50)] + public required string Name { get; set; } - [Required] - [MaxLength(50)] - public required string NormalizedName { get; set; } + [Required] + [MaxLength(50)] + public required string NormalizedName { get; set; } - [MaxLength(256)] - public string? Description { get; set; } + [MaxLength(256)] + public string? Description { get; set; } - public PlayListPictures? Pictures { get; set; } + public PlayListPictures? Pictures { get; set; } - [Required] - public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; + [Required] + public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; - [Required] - public PlaylistVisibility Visibility { get; set; } = PlaylistVisibility.Private; + [Required] + public PlaylistVisibility Visibility { get; set; } = PlaylistVisibility.Private; - [Required] - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - [Required] - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + [Required] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - [ForeignKey(nameof(UserId)) ] - public User User { get; set; } + [ForeignKey(nameof(UserId))] + public User User { get; set; } - public ICollection PlayListTracks { get; set; } = new List(); - } -} \ No newline at end of file + 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 3de229f..836fd6b 100644 --- a/backend/Core/Musify.Domain/Entities/PlayListHasTrack.cs +++ b/backend/Core/Musify.Domain/Entities/PlayListHasTrack.cs @@ -1,30 +1,29 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace Musify.Domain.Entities -{ +namespace Musify.Domain.Entities; + #pragma warning disable CS8618 - [Table("PlayListHasTrack")] - public class PlayListHasTrack - { - public Guid Id { get; set; } = Guid.NewGuid(); +[Table("PlayListHasTrack")] +public class PlayListHasTrack +{ + public Guid Id { get; set; } = Guid.NewGuid(); - [Required] - public required Guid PlayListId { get; set; } + [Required] + public required Guid PlayListId { get; set; } - [Required] - public required Guid TrackId { get; set; } + [Required] + public required Guid TrackId { get; set; } - [Required] - public int Position { get; set; } = 0; + [Required] + public int Position { get; set; } = 0; - [Required] - public DateTime AddedAt { get; set; } = DateTime.UtcNow; + [Required] + public DateTime AddedAt { get; set; } = DateTime.UtcNow; - [ForeignKey(nameof(PlayListId)) ] - public PlayList PlayList { get; set; } + [ForeignKey(nameof(PlayListId))] + public PlayList PlayList { get; set; } - [ForeignKey(nameof(TrackId)) ] - public Track Track { get; set; } - } -} \ No newline at end of file + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } +} diff --git a/backend/Core/Musify.Domain/Entities/Track.cs b/backend/Core/Musify.Domain/Entities/Track.cs index e892b48..b1c352f 100644 --- a/backend/Core/Musify.Domain/Entities/Track.cs +++ b/backend/Core/Musify.Domain/Entities/Track.cs @@ -3,55 +3,54 @@ using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; using Musify.Domain.ValueObjects; -namespace Musify.Domain.Entities -{ +namespace Musify.Domain.Entities; + #pragma warning disable CS8618 - public class Track : IAuditable - { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); +public class Track : IAuditable +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - [Required] - [MaxLength(200)] - public required string Title { get; set; } + [Required] + [MaxLength(200)] + public required string Title { get; set; } - [Required] - [MaxLength(200)] - public required string NormalizedTitle { get; set; } + [Required] + [MaxLength(200)] + public required string NormalizedTitle { get; set; } - [Required] - public double DurationSeconds { get; set; } + [Required] + public double DurationSeconds { get; set; } - [Required] - public required long OwnerUserId { get; set; } + [Required] + public required long OwnerUserId { get; set; } - [ForeignKey(nameof(OwnerUserId))] - public required User Owner { get; set; } + [ForeignKey(nameof(OwnerUserId))] + public required User Owner { get; set; } - [Required] - public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; + [Required] + public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; - [Required] - public bool IsExplicit { get; set; } + [Required] + public bool IsExplicit { get; set; } - [Required] - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - [Required] - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + [Required] + 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 UserTracks { get; set; } = new List(); + public ICollection UserTracks { get; set; } = new List(); - 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 41ebb9f..b605187 100644 --- a/backend/Core/Musify.Domain/Entities/TrackLike.cs +++ b/backend/Core/Musify.Domain/Entities/TrackLike.cs @@ -1,28 +1,27 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace Musify.Domain.Entities -{ +namespace Musify.Domain.Entities; + #pragma warning disable CS8618 - [Table("TrackLikes")] - public class TrackLike - { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); +[Table("TrackLikes")] +public class TrackLike +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - [Required] - public required long UserId { get; set; } + [Required] + public required long UserId { get; set; } - [Required] - public required Guid TrackId { get; set; } + [Required] + public required Guid TrackId { get; set; } - [Required] - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - [ForeignKey(nameof(UserId))] - public User User { get; set; } + [ForeignKey(nameof(UserId))] + public User User { get; set; } - [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } - } + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } } diff --git a/backend/Core/Musify.Domain/Entities/TrackTag.cs b/backend/Core/Musify.Domain/Entities/TrackTag.cs index 16fece7..771c4cc 100644 --- a/backend/Core/Musify.Domain/Entities/TrackTag.cs +++ b/backend/Core/Musify.Domain/Entities/TrackTag.cs @@ -2,21 +2,20 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.ValueObjects; -namespace Musify.Domain.Entities -{ +namespace Musify.Domain.Entities; + #pragma warning disable CS8618 - public class TrackTag - { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); +public class TrackTag +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - [Required] - public required Guid TrackId { get; set; } + [Required] + public required Guid TrackId { get; set; } - [Required] - public required Genre Tag { get; set; } + [Required] + public required Genre Tag { get; set; } - [ForeignKey(nameof(TrackId))] - public Track Track { get; set; } - } + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } } diff --git a/backend/Core/Musify.Domain/Entities/Upload.cs b/backend/Core/Musify.Domain/Entities/Upload.cs index 1cb9cb7..5c8db80 100644 --- a/backend/Core/Musify.Domain/Entities/Upload.cs +++ b/backend/Core/Musify.Domain/Entities/Upload.cs @@ -1,29 +1,28 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.ValueObjects; -namespace Musify.Domain.Entities +namespace Musify.Domain.Entities; + +[Table("Upload")] +public class Upload { - [Table("Upload")] - public class Upload - { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - [Required] - public required string Bucket { get; set; } + [Required] + public required string Bucket { get; set; } - [Required] - public required string Key { get; set; } + [Required] + public required string Key { get; set; } - [Required] - public required string ContentType { get; set; } + [Required] + public required string ContentType { get; set; } - [Required] - public DateTime CreatedTime { get; set; } = DateTime.UtcNow; + [Required] + public DateTime CreatedTime { get; set; } = DateTime.UtcNow; - [Required] - public UploadState State { get; set; } = UploadState.Pending; - } -} \ No newline at end of file + [Required] + public UploadState State { get; set; } = UploadState.Pending; +} diff --git a/backend/Core/Musify.Domain/Entities/UploadIntent.cs b/backend/Core/Musify.Domain/Entities/UploadIntent.cs index 7150dd1..0b48567 100644 --- a/backend/Core/Musify.Domain/Entities/UploadIntent.cs +++ b/backend/Core/Musify.Domain/Entities/UploadIntent.cs @@ -3,50 +3,49 @@ using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.ValueObjects; -namespace Musify.Domain.Entities +namespace Musify.Domain.Entities; + +[Table("UploadIntents")] +public class UploadIntent { - [Table("UploadIntents")] - public class UploadIntent - { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - [Required] - public required long UserId { get; set; } + [Required] + public required long UserId { get; set; } - [Required, MaxLength(128)] - public required string Bucket { get; set; } + [Required, MaxLength(128)] + public required string Bucket { get; set; } - [Required, MaxLength(512)] - public required string Key { get; set; } + [Required, MaxLength(512)] + public required string Key { get; set; } - [Required, MaxLength(128)] - public required string ObjectName { get; set; } + [Required, MaxLength(128)] + public required string ObjectName { get; set; } - [Required, MaxLength(128)] - public required string ContentType { get; set; } + [Required, MaxLength(128)] + public required string ContentType { get; set; } - public long? ExpectedSizeBytes { get; set; } + public long? ExpectedSizeBytes { get; set; } - [Required] - public UploadIntentPurpose Purpose { get; set; } + [Required] + public UploadIntentPurpose Purpose { get; set; } - [Required] - public UploadIntentStatus Status { get; set; } = UploadIntentStatus.Issued; + [Required] + public UploadIntentStatus Status { get; set; } = UploadIntentStatus.Issued; - [Required] - public DateTime ExpiresAt { get; set; } + [Required] + public DateTime ExpiresAt { get; set; } - [Required] - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - [NotMapped] - public bool IsConsumed => Status == UploadIntentStatus.Consumed; + [NotMapped] + public bool IsConsumed => Status == UploadIntentStatus.Consumed; - [NotMapped] - public bool IsExpired => Status == UploadIntentStatus.Expired || ExpiresAt < DateTime.UtcNow; + [NotMapped] + public bool IsExpired => Status == UploadIntentStatus.Expired || ExpiresAt < DateTime.UtcNow; - [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 8030e65..2a8c5f6 100644 --- a/backend/Core/Musify.Domain/Entities/User.cs +++ b/backend/Core/Musify.Domain/Entities/User.cs @@ -1,43 +1,42 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Musify.Domain.Abstractions; -namespace Musify.Domain.Entities +namespace Musify.Domain.Entities; + +[Table("User")] +public class User : IAuditable { - [Table("User")] - public class User : IAuditable - { - [Key] - [DatabaseGenerated(DatabaseGeneratedOption.None)] - public required long Id { get; set; } + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.None)] + public required long Id { get; set; } - [Required] - [MaxLength(48)] - public required string Name { get; set; } + [Required] + [MaxLength(48)] + public required string Name { get; set; } - [Required] - [MaxLength(48)] - public required string NormalizedName { get; set; } + [Required] + [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; } - [Required] - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - [Required] - public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + [Required] + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; - public ICollection UserTracks { get; set; } = new List(); + public ICollection UserTracks { get; set; } = new List(); - public ICollection PlayLists { get; set; } = new List(); + public ICollection PlayLists { get; set; } = new List(); - public ICollection ListeningHistories { get; set; } = new List(); - } -} \ No newline at end of file + 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 1cd3322..202c6a0 100644 --- a/backend/Core/Musify.Domain/Entities/UserFollow.cs +++ b/backend/Core/Musify.Domain/Entities/UserFollow.cs @@ -1,28 +1,27 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace Musify.Domain.Entities -{ +namespace Musify.Domain.Entities; + #pragma warning disable CS8618 - [Table("UserFollows")] - public class UserFollow - { - [Key] - public Guid Id { get; set; } = Guid.NewGuid(); +[Table("UserFollows")] +public class UserFollow +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); - [Required] - public required long FollowerId { get; set; } + [Required] + public required long FollowerId { get; set; } - [Required] - public required long FollowedId { get; set; } + [Required] + public required long FollowedId { get; set; } - [Required] - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + [Required] + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - [ForeignKey(nameof(FollowerId))] - public User Follower { get; set; } + [ForeignKey(nameof(FollowerId))] + public User Follower { get; set; } - [ForeignKey(nameof(FollowedId))] - public User Followed { get; set; } - } + [ForeignKey(nameof(FollowedId))] + public User Followed { get; set; } } diff --git a/backend/Core/Musify.Domain/Entities/UserHasTrack.cs b/backend/Core/Musify.Domain/Entities/UserHasTrack.cs index f2e72b3..a1ad0be 100644 --- a/backend/Core/Musify.Domain/Entities/UserHasTrack.cs +++ b/backend/Core/Musify.Domain/Entities/UserHasTrack.cs @@ -1,24 +1,23 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; -namespace Musify.Domain.Entities -{ +namespace Musify.Domain.Entities; + #pragma warning disable CS8618 - [Table("UserHasTrack")] - public class UserHasTrack - { - public Guid Id { get; set; } = Guid.NewGuid(); +[Table("UserHasTrack")] +public class UserHasTrack +{ + public Guid Id { get; set; } = Guid.NewGuid(); - [Required] - public required long UserId { get; set; } + [Required] + public required long UserId { get; set; } - [Required] - public required Guid TrackId { get; set; } + [Required] + public required Guid TrackId { get; set; } - [ForeignKey(nameof(UserId)) ] - public User User { get; set; } + [ForeignKey(nameof(UserId))] + public User User { get; set; } - [ForeignKey(nameof(TrackId)) ] - public Track Track { get; set; } - } -} \ No newline at end of file + [ForeignKey(nameof(TrackId))] + public Track Track { get; set; } +} diff --git a/backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs b/backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs index 26e39c6..82e2626 100644 --- a/backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs +++ b/backend/Core/Musify.Domain/ValueObjects/AlbumPictures.cs @@ -1,19 +1,18 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Domain.ValueObjects +namespace Musify.Domain.ValueObjects; + +public class AlbumPictures { - public class AlbumPictures - { - [Required] - public required string OriginalName { get; set; } + [Required] + public required string OriginalName { get; set; } - [Required] - public required string SmallName { get; set; } + [Required] + public required string SmallName { get; set; } - [Required] - public required string MediumName { get; set; } + [Required] + public required string MediumName { get; set; } - [Required] - public required string LargeName { get; set; } - } + [Required] + public required string LargeName { get; set; } } diff --git a/backend/Core/Musify.Domain/ValueObjects/Genre.cs b/backend/Core/Musify.Domain/ValueObjects/Genre.cs index dc9eeb9..d3db9f1 100644 --- a/backend/Core/Musify.Domain/ValueObjects/Genre.cs +++ b/backend/Core/Musify.Domain/ValueObjects/Genre.cs @@ -1,32 +1,31 @@ -namespace Musify.Domain.ValueObjects +namespace Musify.Domain.ValueObjects; + +public enum Genre { - 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 - } + 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 8b8710c..4cd1733 100644 --- a/backend/Core/Musify.Domain/ValueObjects/GenreCompatibility.cs +++ b/backend/Core/Musify.Domain/ValueObjects/GenreCompatibility.cs @@ -1,48 +1,49 @@ -namespace Musify.Domain.ValueObjects +namespace Musify.Domain.ValueObjects; + + +public static class GenreCompatibility { + private static readonly HashSet<(Genre, Genre)> IncompatiblePairs = BuildIncompatiblePairs(); - public static class GenreCompatibility + private static HashSet<(Genre, Genre)> BuildIncompatiblePairs() { - private static readonly HashSet<(Genre, Genre)> IncompatiblePairs = BuildIncompatiblePairs(); + var pairs = new HashSet<(Genre, Genre)>(); - private static HashSet<(Genre, Genre)> BuildIncompatiblePairs() + void Add(Genre a, Genre b) { - var pairs = new HashSet<(Genre, Genre)>(); - - void Add(Genre a, Genre b) - { - 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; + pairs.Add((a, b)); + pairs.Add((b, a)); } - public static bool AreCompatible(Genre a, Genre b) => - a == b || !IncompatiblePairs.Contains((a, b)); + var intense = new[] { Genre.Metal, Genre.Punk, Genre.Dubstep, Genre.DrumAndBass }; + var mellow = new[] { Genre.Classical, Genre.Ambient, Genre.Lofi }; - public static IReadOnlyCollection<(Genre First, Genre Second)> FindConflicts(IEnumerable tags) + foreach (var intenseGenre in intense) { - 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; + 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++) + { + 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 4e21417..04f9e20 100644 --- a/backend/Core/Musify.Domain/ValueObjects/LifeCycleStatus.cs +++ b/backend/Core/Musify.Domain/ValueObjects/LifeCycleStatus.cs @@ -1,9 +1,8 @@ -namespace Musify.Domain.ValueObjects +namespace Musify.Domain.ValueObjects; + +public enum LifeCycleStatus { - public enum LifeCycleStatus - { - Active = 0, - Removing = 1, - Failed = 2 - } + Active = 0, + Removing = 1, + Failed = 2 } diff --git a/backend/Core/Musify.Domain/ValueObjects/PlayListPictures.cs b/backend/Core/Musify.Domain/ValueObjects/PlayListPictures.cs index 7c701a9..df8dc3e 100644 --- a/backend/Core/Musify.Domain/ValueObjects/PlayListPictures.cs +++ b/backend/Core/Musify.Domain/ValueObjects/PlayListPictures.cs @@ -1,19 +1,18 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Domain.ValueObjects +namespace Musify.Domain.ValueObjects; + +public class PlayListPictures { - public class PlayListPictures - { - [Required] - public required string OriginalName { get; set; } + [Required] + public required string OriginalName { get; set; } - [Required] - public required string SmallName { get; set; } + [Required] + public required string SmallName { get; set; } - [Required] - public required string MediumName { get; set; } + [Required] + public required string MediumName { get; set; } - [Required] - public required string LargeName { get; set; } - } + [Required] + public required string LargeName { get; set; } } diff --git a/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs b/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs index f809e24..e2769f0 100644 --- a/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs +++ b/backend/Core/Musify.Domain/ValueObjects/PlaylistVisibility.cs @@ -1,8 +1,7 @@ -namespace Musify.Domain.ValueObjects +namespace Musify.Domain.ValueObjects; + +public enum PlaylistVisibility { - public enum PlaylistVisibility - { - Private = 0, - Public = 1 - } + Private = 0, + Public = 1 } diff --git a/backend/Core/Musify.Domain/ValueObjects/ProcessingStatus.cs b/backend/Core/Musify.Domain/ValueObjects/ProcessingStatus.cs index 66aa32e..d2f681e 100644 --- a/backend/Core/Musify.Domain/ValueObjects/ProcessingStatus.cs +++ b/backend/Core/Musify.Domain/ValueObjects/ProcessingStatus.cs @@ -1,10 +1,9 @@ -namespace Musify.Domain.ValueObjects +namespace Musify.Domain.ValueObjects; + +public enum ProcessingStatus { - public enum ProcessingStatus - { - Pending = 0, - Processing = 1, - Completed = 2, - Failed = 3 - } -} \ No newline at end of file + 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 2333465..c1c3148 100644 --- a/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs +++ b/backend/Core/Musify.Domain/ValueObjects/TrackAudio.cs @@ -1,27 +1,26 @@ using System.Diagnostics.CodeAnalysis; -namespace Musify.Domain.ValueObjects -{ +namespace Musify.Domain.ValueObjects; + #pragma warning disable CS8618 - public class TrackAudio - { - 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; - public bool DownloadRequested { get; set; } + public bool DownloadRequested { get; set; } - public int RetryCount { get; set; } + public int RetryCount { get; set; } - public DateTime? LastRetryAt { get; set; } + public DateTime? LastRetryAt { get; set; } - [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 143d9da..511f658 100644 --- a/backend/Core/Musify.Domain/ValueObjects/TrackPictures.cs +++ b/backend/Core/Musify.Domain/ValueObjects/TrackPictures.cs @@ -1,23 +1,22 @@ using System.Diagnostics.CodeAnalysis; -namespace Musify.Domain.ValueObjects -{ +namespace Musify.Domain.ValueObjects; + #pragma warning disable CS8618 - public class TrackPictures - { - 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 9629594..9d98403 100644 --- a/backend/Core/Musify.Domain/ValueObjects/UploadIntentPurpose.cs +++ b/backend/Core/Musify.Domain/ValueObjects/UploadIntentPurpose.cs @@ -1,10 +1,9 @@ -namespace Musify.Domain.ValueObjects +namespace Musify.Domain.ValueObjects; + +public enum UploadIntentPurpose { - public enum UploadIntentPurpose - { - PlayListPicture, - TrackPicture, - TrackAudio, - AlbumPicture, - } + PlayListPicture, + TrackPicture, + TrackAudio, + AlbumPicture, } diff --git a/backend/Core/Musify.Domain/ValueObjects/UploadIntentStatus.cs b/backend/Core/Musify.Domain/ValueObjects/UploadIntentStatus.cs index 7a6e23f..85f3934 100644 --- a/backend/Core/Musify.Domain/ValueObjects/UploadIntentStatus.cs +++ b/backend/Core/Musify.Domain/ValueObjects/UploadIntentStatus.cs @@ -1,9 +1,8 @@ -namespace Musify.Domain.ValueObjects +namespace Musify.Domain.ValueObjects; + +public enum UploadIntentStatus { - public enum UploadIntentStatus - { - Issued, - Consumed, - Expired, - } + Issued, + Consumed, + Expired, } diff --git a/backend/Core/Musify.Domain/ValueObjects/UploadState.cs b/backend/Core/Musify.Domain/ValueObjects/UploadState.cs index 5bcaf5b..729378b 100644 --- a/backend/Core/Musify.Domain/ValueObjects/UploadState.cs +++ b/backend/Core/Musify.Domain/ValueObjects/UploadState.cs @@ -1,9 +1,8 @@ -namespace Musify.Domain.ValueObjects +namespace Musify.Domain.ValueObjects; + +public enum UploadState { - public enum UploadState - { - Pending, - Failed, - Successful, - } -} \ No newline at end of file + Pending, + Failed, + Successful, +} diff --git a/backend/Core/Musify.Infrastructure/Configuration/AudioConfiguration.cs b/backend/Core/Musify.Infrastructure/Configuration/AudioConfiguration.cs index b1cb2a9..b4573a6 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/AudioConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/AudioConfiguration.cs @@ -1,41 +1,40 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; -namespace Musify.Infrastructure.Configuration +namespace Musify.Infrastructure.Configuration; + +public class AudioConfiguration { - public class AudioConfiguration - { - public const string SectionName = "AudioTranscoder"; + public const 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; - } -} \ No newline at end of file + 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 e79f5ed..63a5eca 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/DatabaseConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/DatabaseConfiguration.cs @@ -1,12 +1,11 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; -namespace Musify.Infrastructure.Configuration +namespace Musify.Infrastructure.Configuration; + +public class DatabaseConfiguration { - public class DatabaseConfiguration - { - public const string SectionName = "Database"; + public const string SectionName = "Database"; - [Required] - public required string ConnectionString { get; set; } - } -} \ No newline at end of file + [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 2c2afb7..948c513 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/InfrastructureStorageConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/InfrastructureStorageConfiguration.cs @@ -1,25 +1,24 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Infrastructure.Configuration +namespace Musify.Infrastructure.Configuration; + +public class InfrastructureStorageConfiguration { - public class InfrastructureStorageConfiguration - { - public const string SectionName = "InfrastructureStorage"; + public const 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 df216ee..685492d 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/MassTransitConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/MassTransitConfiguration.cs @@ -1,18 +1,17 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Infrastructure.Configuration +namespace Musify.Infrastructure.Configuration; + +public class MassTransitConfiguration { - public class MassTransitConfiguration - { - public const string SectionName = "MassTransit"; + public const 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 e435db1..5c7d074 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/OpenTelemetryConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/OpenTelemetryConfiguration.cs @@ -1,13 +1,12 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Infrastructure.Configuration -{ - public class OpenTelemetryConfiguration - { - public const string SectionName = "OpenTelemetry"; +namespace Musify.Infrastructure.Configuration; - [Url] - [Required] - public required string OtlpEndpoint { get; set; } - } +public class OpenTelemetryConfiguration +{ + public const string SectionName = "OpenTelemetry"; + + [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 0773c90..9097609 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/StreamTicketConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/StreamTicketConfiguration.cs @@ -1,21 +1,20 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Infrastructure.Configuration +namespace Musify.Infrastructure.Configuration; + +public class StreamTicketConfiguration { - public class StreamTicketConfiguration - { - public const string SectionName = "StreamTicket"; + public const 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 fa33cae..4cc48ed 100644 --- a/backend/Core/Musify.Infrastructure/Configuration/WorkerConfiguration.cs +++ b/backend/Core/Musify.Infrastructure/Configuration/WorkerConfiguration.cs @@ -1,18 +1,17 @@ using System.ComponentModel.DataAnnotations; -namespace Musify.Infrastructure.Configuration +namespace Musify.Infrastructure.Configuration; + +public class WorkerConfiguration { - public class WorkerConfiguration - { - public const string SectionName = "Workers"; + public const 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 4c7f39c..a0128b3 100644 --- a/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs +++ b/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs @@ -4,35 +4,34 @@ using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Application.Mixes; -namespace Musify.Infrastructure.Jobs +namespace Musify.Infrastructure.Jobs; + +public class DailyMixGenerationJob( + IDatabase database, + IMediator mediator, + ILogger logger) { - public class DailyMixGenerationJob( - IDatabase database, - IMediator mediator, - ILogger logger) + public async Task RunAsync(CancellationToken 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); + + foreach (var userId in userIds) { - 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); - - foreach (var userId in userIds) + var result = await mediator.Send(new GenerateMixesForUserCommand(userId), cancellationToken); + if (result.IsError) { - var result = await mediator.Send(new GenerateMixesForUserCommand(userId), cancellationToken); - if (result.IsError) - { - logger.LogWarning("Failed to generate mixes for user {UserId}: {Error}", - userId, result.FirstError.Description); - } + logger.LogWarning("Failed to generate mixes for user {UserId}: {Error}", + userId, result.FirstError.Description); } - - 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 fc8e1b3..9a91b3c 100644 --- a/backend/Core/Musify.Infrastructure/Jobs/ListeningHistoryCleanupJob.cs +++ b/backend/Core/Musify.Infrastructure/Jobs/ListeningHistoryCleanupJob.cs @@ -1,13 +1,12 @@ using Mediator; using Musify.Application.Tracks; -namespace Musify.Infrastructure.Jobs +namespace Musify.Infrastructure.Jobs; + +public class ListeningHistoryCleanupJob(IMediator mediator) { - public class ListeningHistoryCleanupJob(IMediator mediator) + public async Task RunAsync(CancellationToken cancellationToken) { - public async Task RunAsync(CancellationToken cancellationToken) - { - await mediator.Send(new DeleteStaleUncountedListensCommand(), cancellationToken); - } + await mediator.Send(new DeleteStaleUncountedListensCommand(), cancellationToken); } } diff --git a/backend/Core/Musify.Infrastructure/Jobs/TemporalUploadsCleanupJob.cs b/backend/Core/Musify.Infrastructure/Jobs/TemporalUploadsCleanupJob.cs index ee0e0d4..3f17c5c 100644 --- a/backend/Core/Musify.Infrastructure/Jobs/TemporalUploadsCleanupJob.cs +++ b/backend/Core/Musify.Infrastructure/Jobs/TemporalUploadsCleanupJob.cs @@ -4,68 +4,67 @@ using Microsoft.Extensions.Logging; using Musify.Application.Configuration; using Musify.Application.Contracts; -namespace Musify.Infrastructure.Jobs +namespace Musify.Infrastructure.Jobs; + +public class TemporalUploadsCleanUpJob( + IServiceScopeFactory scopeFactory, + ILogger logger, + ApplicationStorageConfiguration storageConfiguration, + UploadIntentConfiguration uploadIntentConfiguration) + : BackgroundService { - public class TemporalUploadsCleanUpJob( - IServiceScopeFactory scopeFactory, - ILogger logger, - ApplicationStorageConfiguration storageConfiguration, - UploadIntentConfiguration uploadIntentConfiguration) - : BackgroundService + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - protected override async Task ExecuteAsync(CancellationToken stoppingToken) + logger.LogInformation("TemporalUploadsCleanUpJob started (interval: {Interval}s, prefix: {Prefix})", + uploadIntentConfiguration.TemporalCleanUpJobIntervalSeconds, + uploadIntentConfiguration.TemporalRootPrefix); + + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(uploadIntentConfiguration.TemporalCleanUpJobIntervalSeconds)); + + do { - logger.LogInformation("TemporalUploadsCleanUpJob started (interval: {Interval}s, prefix: {Prefix} )", - uploadIntentConfiguration.TemporalCleanUpJobIntervalSeconds, - uploadIntentConfiguration.TemporalRootPrefix); - - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(uploadIntentConfiguration.TemporalCleanUpJobIntervalSeconds)); - - do + try { - try - { - await RunAsync(stoppingToken); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - logger.LogError(exception, "Error in TemporalUploadsCleanUpJob"); - } + await RunAsync(stoppingToken); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + logger.LogError(exception, "Error in TemporalUploadsCleanUpJob"); + } + } + while (await timer.WaitForNextTickAsync(stoppingToken)); + } + + private async Task RunAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var storageService = scope.ServiceProvider.GetRequiredService(); + + var cutOff = DateTime.UtcNow.AddDays(-uploadIntentConfiguration.TemporalUploadsRetentionDays); + var deleted = 0; + var errors = 0; + + await foreach (var (key, lastModified) in storageService.ListObjectsAsync( + storageConfiguration.Bucket, + uploadIntentConfiguration.TemporalRootPrefix, + cancellationToken)) + { + if (lastModified >= cutOff) + continue; + + try + { + await storageService.RemoveFileAsync(storageConfiguration.Bucket, key, cancellationToken); + deleted++; + } + catch (Exception exception) + { + errors++; + logger.LogWarning(exception, "Failed to delete orphaned temp object {Key}", key); } - while (await timer.WaitForNextTickAsync(stoppingToken)); } - async Task RunAsync(CancellationToken cancellationToken) - { - await using var scope = scopeFactory.CreateAsyncScope(); - var storageService = scope.ServiceProvider.GetRequiredService(); - - var cutOff = DateTime.UtcNow.AddDays(-uploadIntentConfiguration.TemporalUploadsRetentionDays); - var deleted = 0; - var errors = 0; - - await foreach (var (key, lastModified) in storageService.ListObjectsAsync( - storageConfiguration.Bucket, - uploadIntentConfiguration.TemporalRootPrefix, - cancellationToken)) - { - if (lastModified >= cutOff) - continue; - - try - { - await storageService.RemoveFileAsync(storageConfiguration.Bucket, key, cancellationToken); - deleted++; - } - catch (Exception exception) - { - errors++; - logger.LogWarning(exception, "Failed to delete orphaned temp object {Key}", key); - } - } - - if (deleted > 0 || errors > 0) - logger.LogInformation("TemporalUploadsCleanUpJob: deleted {Deleted} orphaned objects, {Errors} errors", deleted, errors); - } + if (deleted > 0 || errors > 0) + logger.LogInformation("TemporalUploadsCleanUpJob: deleted {Deleted} orphaned objects, {Errors} errors", deleted, errors); } } diff --git a/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs b/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs index 5bf9192..c2bc8f0 100644 --- a/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs +++ b/backend/Core/Musify.Infrastructure/Jobs/UploadIntentExpirationJob.cs @@ -3,71 +3,70 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Musify.Application.Configuration; -using Musify.Domain.ValueObjects; using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; -namespace Musify.Infrastructure.Jobs +namespace Musify.Infrastructure.Jobs; + +public class UploadIntentExpirationJob( + IServiceScopeFactory scopeFactory, + ILogger logger, + UploadIntentConfiguration uploadIntentConfiguration) + : BackgroundService { - public class UploadIntentExpirationJob( - IServiceScopeFactory scopeFactory, - ILogger logger, - UploadIntentConfiguration uploadIntentConfiguration) - : BackgroundService + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - protected override async Task ExecuteAsync(CancellationToken stoppingToken) + logger.LogInformation("UploadIntentExpirationJob started (interval: {Interval}s)", uploadIntentConfiguration.ExpirationJobIntervalSeconds); + + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(uploadIntentConfiguration.ExpirationJobIntervalSeconds)); + + do { - logger.LogInformation("UploadIntentExpirationJob started (interval: {Interval}s)", uploadIntentConfiguration.ExpirationJobIntervalSeconds); - - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(uploadIntentConfiguration.ExpirationJobIntervalSeconds)); - - do + try { - try - { - await RunAsync(stoppingToken); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - logger.LogError(exception, "Error in UploadIntentExpirationJob"); - } + await RunAsync(stoppingToken); } - while (await timer.WaitForNextTickAsync(stoppingToken)); + catch (Exception exception) when (exception is not OperationCanceledException) + { + logger.LogError(exception, "Error in UploadIntentExpirationJob"); + } + } + while (await timer.WaitForNextTickAsync(stoppingToken)); + } + + private async Task RunAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var database = scope.ServiceProvider.GetRequiredService(); + + var now = DateTime.UtcNow; + + var expiredIntents = await database.UploadIntents + .Where(i => i.Status == UploadIntentStatus.Issued && i.ExpiresAt < now) + .ToListAsync(cancellationToken); + + if (expiredIntents.Count > 0) + { + foreach (var intent in expiredIntents) + intent.Status = UploadIntentStatus.Expired; + + await database.SaveChangesAsync(cancellationToken); + logger.LogInformation("Marked {Count} upload intents as Expired", expiredIntents.Count); } - async Task RunAsync(CancellationToken cancellationToken) + if (uploadIntentConfiguration.ExpiredIntentsRetentionDays >= 0) { - await using var scope = scopeFactory.CreateAsyncScope(); - var database = scope.ServiceProvider.GetRequiredService(); + var retentionCutoff = now.AddDays(-uploadIntentConfiguration.ExpiredIntentsRetentionDays); - var now = DateTime.UtcNow; - - var expiredIntents = await database.UploadIntents - .Where(i => i.Status == UploadIntentStatus.Issued && i.ExpiresAt < now) + var oldExpiredIntents = await database.UploadIntents + .Where(i => i.Status == UploadIntentStatus.Expired && i.ExpiresAt < retentionCutoff) .ToListAsync(cancellationToken); - if (expiredIntents.Count > 0) + if (oldExpiredIntents.Count > 0) { - foreach (var intent in expiredIntents) - intent.Status = UploadIntentStatus.Expired; - + database.UploadIntents.RemoveRange(oldExpiredIntents); await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Marked {Count} upload intents as Expired", expiredIntents.Count); - } - - if (uploadIntentConfiguration.ExpiredIntentsRetentionDays >= 0) - { - var retentionCutoff = now.AddDays(-uploadIntentConfiguration.ExpiredIntentsRetentionDays); - - var oldExpiredIntents = await database.UploadIntents - .Where(i => i.Status == UploadIntentStatus.Expired && i.ExpiresAt < retentionCutoff) - .ToListAsync(cancellationToken); - - if (oldExpiredIntents.Count > 0) - { - database.UploadIntents.RemoveRange(oldExpiredIntents); - await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("Deleted {Count} old Expired upload intents past retention window", oldExpiredIntents.Count); - } + logger.LogInformation("Deleted {Count} old Expired upload intents past retention window", oldExpiredIntents.Count); } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsFailedActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsFailedActivity.cs index 9e55e31..c75652f 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsFailedActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/MarkAlbumAsFailedActivity.cs @@ -5,28 +5,27 @@ using Musify.Application.Contracts; using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.Activities.Albums +namespace Musify.Infrastructure.MassTransit.Activities.Albums; + +internal class MarkAlbumAsFailedActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity { - internal class MarkAlbumAsFailedActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity + public const string ExecuteEndpointName = "mark-album-as-failed"; + + public async Task Execute(ExecuteContext executeContext) { - public const string ExecuteEndpointName = "mark-album-as-failed"; + var album = await database.Albums + .SingleOrDefaultAsync(a => a.Id == executeContext.Arguments.AlbumId, executeContext.CancellationToken); - public async Task Execute(ExecuteContext executeContext) + if (album != null) { - var album = await database.Albums - .SingleOrDefaultAsync(a => a.Id == executeContext.Arguments.AlbumId, executeContext.CancellationToken); - - if (album != null) - { - album.LifeCycleStatus = LifeCycleStatus.Failed; - await database.SaveChangesAsync(executeContext.CancellationToken); - logger.LogInformation("Marked album {AlbumId} as failed", executeContext.Arguments.AlbumId); - } - - return executeContext.Completed(); + album.LifeCycleStatus = LifeCycleStatus.Failed; + await database.SaveChangesAsync(executeContext.CancellationToken); + logger.LogInformation("Marked album {AlbumId} as failed", executeContext.Arguments.AlbumId); } + + return executeContext.Completed(); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs index 0db2cab..c5c15c1 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Albums/PublishAlbumPictureProcessingEventActivity.cs @@ -3,32 +3,31 @@ using Microsoft.Extensions.Logging; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.Activities.Albums +namespace Musify.Infrastructure.MassTransit.Activities.Albums; + +internal class PublishAlbumPictureProcessingEventActivity( + IPublishEndpoint publishEndpoint, + ILogger logger) + : IExecuteActivity { - internal class PublishAlbumPictureProcessingEventActivity( - IPublishEndpoint publishEndpoint, - ILogger logger) - : IExecuteActivity + public const string ExecuteEndpointName = "publish-album-picture-processing-event"; + + public async Task Execute(ExecuteContext executeContext) { - public const string ExecuteEndpointName = "publish-album-picture-processing-event"; + var arguments = executeContext.Arguments; - public async Task Execute(ExecuteContext executeContext) - { - var arguments = executeContext.Arguments; + await publishEndpoint.Publish( + new UpdateAlbumPictureEvent( + arguments.AlbumId, + arguments.Bucket, + arguments.FinalPictureKey, + arguments.Small, + arguments.Medium, + arguments.Large), + executeContext.CancellationToken); - await publishEndpoint.Publish( - new UpdateAlbumPictureEvent( - arguments.AlbumId, - arguments.Bucket, - arguments.FinalPictureKey, - arguments.Small, - arguments.Medium, - arguments.Large), - executeContext.CancellationToken); + logger.LogInformation("Published picture processing event for album {AlbumId}", arguments.AlbumId); - logger.LogInformation("Published picture processing event for album {AlbumId}", arguments.AlbumId); - - return executeContext.Completed(); - } + 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 85b5216..edef0a9 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/GenerateAudioWorkflowPathsActivity.cs @@ -6,68 +6,67 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.Activities.Audio +namespace Musify.Infrastructure.MassTransit.Activities.Audio; + +internal class GenerateAudioWorkflowPathsActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity { - internal class GenerateAudioWorkflowPathsActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity + public const string ExecuteEndpointName = "generate-audio-workflow-paths"; + + public async Task Execute(ExecuteContext executeContext) { - public const string ExecuteEndpointName = "generate-audio-workflow-paths"; - - public async Task Execute(ExecuteContext executeContext) + Track track; + try { - Track track; - try + var getTrack = await database.Tracks.SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); + track = getTrack ?? throw new InvalidOperationException($"Track with id {executeContext.Arguments.TrackId} not found."); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to retrieve track for TrackId {TrackId}", + executeContext.Arguments.TrackId); + throw; + } + + try + { + track.Audio.TranscodeStatus = ProcessingStatus.Processing; + var folderName = Guid.NewGuid().ToString(); + var sourceFileName = Path.GetFileName(executeContext.Arguments.SourceKey); + var workingDirectory = Path.Combine(executeContext.Arguments.TemporaryRootDirectory, folderName); + var sourceFilePath = Path.Combine(workingDirectory, sourceFileName); + + Directory.CreateDirectory(workingDirectory); + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogDebug("Generated audio workflow paths in {WorkingDirectory}", workingDirectory); + + return executeContext.CompletedWithVariables(new Dictionary { - var getTrack = await database.Tracks.SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); - track = getTrack ?? throw new InvalidOperationException($"Track with id {executeContext.Arguments.TrackId} not found."); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to retrieve track for TrackId {TrackId}", - executeContext.Arguments.TrackId); - throw; - } + [RoutingSlipVariableNames.Workflow.TemporalDirectory] = workingDirectory, + [RoutingSlipVariableNames.Audio.SourceFilePath] = sourceFilePath, + [RoutingSlipVariableNames.Audio.TranscodedDirectory] = workingDirectory + }); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to generate audio workflow paths for {SourceKey}", + executeContext.Arguments.SourceKey); try { - track.Audio.TranscodeStatus = ProcessingStatus.Processing; - var folderName = Guid.NewGuid().ToString(); - var sourceFileName = Path.GetFileName(executeContext.Arguments.SourceKey); - var workingDirectory = Path.Combine(executeContext.Arguments.TemporaryRootDirectory, folderName); - var sourceFilePath = Path.Combine(workingDirectory, sourceFileName); - - Directory.CreateDirectory(workingDirectory); + track.Audio.TranscodeStatus = ProcessingStatus.Failed; 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 (Exception exception) + catch (Exception dbException) { - logger.LogError(exception, "Failed to generate audio workflow paths for {SourceKey}", - executeContext.Arguments.SourceKey); - - try - { - track.Audio.TranscodeStatus = ProcessingStatus.Failed; - await database.SaveChangesAsync(executeContext.CancellationToken); - } - catch (Exception dbException) - { - logger.LogError(dbException, "Failed to update ProcessingStatus to Failed for track {TrackId}", - executeContext.Arguments.TrackId); - } - - throw; + logger.LogError(dbException, "Failed to update ProcessingStatus to Failed for track {TrackId}", + executeContext.Arguments.TrackId); } + + throw; } } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs index c09875c..6cf7d56 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/TranscodeAudioActivity.cs @@ -7,92 +7,96 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Audio +namespace Musify.Infrastructure.MassTransit.Activities.Audio; + +internal class TranscodeAudioActivity( + IDatabase database, + IAudioTranscoderService audioTranscoder, + ILogger logger) + : IActivity { - internal class TranscodeAudioActivity( - IDatabase database, - IAudioTranscoderService audioTranscoder, - ILogger logger) - : IActivity + public const string ExecuteEndpointName = "transcode-audio"; + + public async Task Execute(ExecuteContext executeContext) { - public const string ExecuteEndpointName = "transcode-audio"; + var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.SourceFilePathVariable); + ArgumentNullException.ThrowIfNull(sourceFilePath, nameof(sourceFilePath)); + var workingDirectory = executeContext.GetVariable(executeContext.Arguments.WorkingDirectoryVariable); + ArgumentNullException.ThrowIfNull(workingDirectory, nameof(workingDirectory)); - public async Task Execute(ExecuteContext executeContext) + Track track; + try { - var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.SourceFilePathVariable); - ArgumentNullException.ThrowIfNull(sourceFilePath, nameof(sourceFilePath)); - var workingDirectory = executeContext.GetVariable(executeContext.Arguments.WorkingDirectoryVariable); - ArgumentNullException.ThrowIfNull(workingDirectory, nameof(workingDirectory)); - - Track track; - try - { - var getTrack = await database.Tracks.SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); - track = getTrack ?? throw new InvalidOperationException($"Track with Id {executeContext.Arguments.TrackId} not found"); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to retrieve track with Id {TrackId} from database", executeContext.Arguments.TrackId); - throw; - } - - try - { - 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); - - var log = new TranscodeAudioLog(workingDirectory); - return executeContext.CompletedWithVariables(log, new Dictionary - { - [RoutingSlipVariableNames.Audio.DurationSeconds] = (int)Math.Round(transcodeResult.Duration.TotalSeconds) - } ); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to transcode {SourceFilePath}", sourceFilePath); - - try - { - track.Audio.TranscodeStatus = ProcessingStatus.Failed; - await database.SaveChangesAsync(executeContext.CancellationToken); - } - catch (Exception dbException) - { - logger.LogError(dbException, "Failed to update ProcessingStatus to Failed for track {TrackId}", - executeContext.Arguments.TrackId); - } - - throw; - } + var getTrack = await database.Tracks.SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); + track = getTrack ?? throw new InvalidOperationException($"Track with Id {executeContext.Arguments.TrackId} not found"); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to retrieve track with Id {TrackId} from database", executeContext.Arguments.TrackId); + throw; } - public Task Compensate(CompensateContext compensateContext) + try { + 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); + + var log = new TranscodeAudioLog(workingDirectory); + return executeContext.CompletedWithVariables(log, new Dictionary + { + [RoutingSlipVariableNames.Audio.DurationSeconds] = (int)Math.Round(transcodeResult.Duration.TotalSeconds) + }); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to transcode {SourceFilePath}", sourceFilePath); + try { - if (Directory.Exists(compensateContext.Log.WorkingDirectory)) - Directory.Delete(compensateContext.Log.WorkingDirectory, recursive: true); - else - logger.LogWarning("Working directory {WorkingDirectory} not found during compensation", - compensateContext.Log.WorkingDirectory); - return Task.FromResult(compensateContext.Compensated()); + track.Audio.TranscodeStatus = ProcessingStatus.Failed; + await database.SaveChangesAsync(executeContext.CancellationToken); } - catch (Exception exception) + catch (Exception dbException) { - logger.LogError(exception, "Failed to compensate transcoded audio directory {WorkingDirectory}", compensateContext.Log.WorkingDirectory); - return Task.FromResult(compensateContext.Failed(exception)); + logger.LogError(dbException, "Failed to update ProcessingStatus to Failed for track {TrackId}", + executeContext.Arguments.TrackId); } + + throw; + } + } + + public Task Compensate(CompensateContext compensateContext) + { + try + { + if (Directory.Exists(compensateContext.Log.WorkingDirectory)) + { + Directory.Delete(compensateContext.Log.WorkingDirectory, recursive: true); + } + else + { + logger.LogWarning("Working directory {WorkingDirectory} not found during compensation", + compensateContext.Log.WorkingDirectory); + } + + return Task.FromResult(compensateContext.Compensated()); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate transcoded audio directory {WorkingDirectory}", compensateContext.Log.WorkingDirectory); + return Task.FromResult(compensateContext.Failed(exception)); } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs index 472e7fc..06b72ab 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Audio/UpdateTrackAudioActivity.cs @@ -8,109 +8,108 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Audio +namespace Musify.Infrastructure.MassTransit.Activities.Audio; + +internal class UpdateTrackAudioActivity( + IDatabase database, + IPublishEndpoint publishEndpoint, + ILogger logger) + : IActivity { - internal class UpdateTrackAudioActivity( - IDatabase database, - IPublishEndpoint publishEndpoint, - ILogger logger) - : IActivity + public const string ExecuteEndpointName = "update-track-audio"; + + public async Task Execute(ExecuteContext executeContext) { - public const string ExecuteEndpointName = "update-track-audio"; - - public async Task Execute(ExecuteContext executeContext) + Track track; + try { - Track track; - try - { - var getTrack = await database.Tracks.SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); - track = getTrack ?? throw new InvalidOperationException($"Track with id {executeContext.Arguments.TrackId} not found"); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to retrieve track {TrackId}", - executeContext.Arguments.TrackId); - throw; - } - - var audioFolderKey = executeContext.Arguments.AudioFolderKey; - logger.LogDebug("Resolved audio folder key: {AudioFolderKey}", audioFolderKey); - - if (string.IsNullOrWhiteSpace(audioFolderKey)) - { - logger.LogWarning("Audio folder key is empty for track {TrackId}; skipping update", track.Id); - await publishEndpoint.Publish( - new TrackAudioProcessed(executeContext.Arguments.TrackId), - executeContext.CancellationToken); - return executeContext.Completed(); - } - - try - { - var durationSeconds = executeContext.GetVariable(executeContext.Arguments.DurationSecondsVariable) - ?? throw new InvalidOperationException($"Missing routing slip variable {executeContext.Arguments.DurationSecondsVariable}"); - - var log = new UpdateTrackAudioLog( - track.Id, - track.Audio.FolderName, - track.DurationSeconds); - - track.Audio.FolderName = Path.GetFileName(audioFolderKey); - track.DurationSeconds = durationSeconds; - track.Audio.TranscodeStatus = ProcessingStatus.Completed; - await database.SaveChangesAsync(executeContext.CancellationToken); - - logger.LogInformation("Updated track {TrackId} audio", executeContext.Arguments.TrackId); - - await publishEndpoint.Publish( - new TrackAudioProcessed(executeContext.Arguments.TrackId), - executeContext.CancellationToken); - - return executeContext.Completed(log); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to update track {TrackId} audio", - executeContext.Arguments.TrackId); - - try - { - track.Audio.TranscodeStatus = ProcessingStatus.Failed; - await database.SaveChangesAsync(executeContext.CancellationToken); - } - catch (Exception dbException) - { - logger.LogError(dbException, "Failed to update ProcessingStatus to Failed for track {TrackId}", - executeContext.Arguments.TrackId); - } - - throw; - } + var getTrack = await database.Tracks.SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); + track = getTrack ?? throw new InvalidOperationException($"Track with id {executeContext.Arguments.TrackId} not found"); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to retrieve track {TrackId}", + executeContext.Arguments.TrackId); + throw; } - public async Task Compensate(CompensateContext compensateContext) + var audioFolderKey = executeContext.Arguments.AudioFolderKey; + logger.LogDebug("Resolved audio folder key: {AudioFolderKey}", audioFolderKey); + + if (string.IsNullOrWhiteSpace(audioFolderKey)) { + logger.LogWarning("Audio folder key is empty for track {TrackId}; skipping update", track.Id); + await publishEndpoint.Publish( + new TrackAudioProcessed(executeContext.Arguments.TrackId), + executeContext.CancellationToken); + return executeContext.Completed(); + } + + try + { + var durationSeconds = executeContext.GetVariable(executeContext.Arguments.DurationSecondsVariable) + ?? throw new InvalidOperationException($"Missing routing slip variable {executeContext.Arguments.DurationSecondsVariable}"); + + var log = new UpdateTrackAudioLog( + track.Id, + track.Audio.FolderName, + track.DurationSeconds); + + track.Audio.FolderName = Path.GetFileName(audioFolderKey); + track.DurationSeconds = durationSeconds; + track.Audio.TranscodeStatus = ProcessingStatus.Completed; + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Updated track {TrackId} audio", executeContext.Arguments.TrackId); + + await publishEndpoint.Publish( + new TrackAudioProcessed(executeContext.Arguments.TrackId), + executeContext.CancellationToken); + + return executeContext.Completed(log); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to update track {TrackId} audio", + executeContext.Arguments.TrackId); + try { - var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == compensateContext.Log.TrackId, compensateContext.CancellationToken); - if (track == null) - throw new InvalidOperationException($"Track with id {compensateContext.Log.TrackId} not found"); - - track.Audio.FolderName = compensateContext.Log.PreviousAudioFolderName; - track.DurationSeconds = compensateContext.Log.PreviousDurationSeconds; track.Audio.TranscodeStatus = ProcessingStatus.Failed; - await database.SaveChangesAsync(compensateContext.CancellationToken); - - logger.LogInformation("Compensated track {TrackId} audio", compensateContext.Log.TrackId); - - return compensateContext.Compensated(); + await database.SaveChangesAsync(executeContext.CancellationToken); } - catch (Exception exception) + catch (Exception dbException) { - logger.LogError(exception, "Failed to compensate track {TrackId} audio", - compensateContext.Log.TrackId); - return compensateContext.Failed(exception); + logger.LogError(dbException, "Failed to update ProcessingStatus to Failed for track {TrackId}", + executeContext.Arguments.TrackId); } + + throw; } } -} \ No newline at end of file + + public async Task Compensate(CompensateContext compensateContext) + { + try + { + var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == compensateContext.Log.TrackId, compensateContext.CancellationToken); + if (track == null) + throw new InvalidOperationException($"Track with id {compensateContext.Log.TrackId} not found"); + + track.Audio.FolderName = compensateContext.Log.PreviousAudioFolderName; + track.DurationSeconds = compensateContext.Log.PreviousDurationSeconds; + track.Audio.TranscodeStatus = ProcessingStatus.Failed; + await database.SaveChangesAsync(compensateContext.CancellationToken); + + logger.LogInformation("Compensated track {TrackId} audio", compensateContext.Log.TrackId); + + return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate track {TrackId} audio", + compensateContext.Log.TrackId); + return compensateContext.Failed(exception); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs index 22e061a..727b65c 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/CopyFileInBucketActivity.cs @@ -4,58 +4,57 @@ using Musify.Application.Contracts; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Files +namespace Musify.Infrastructure.MassTransit.Activities.Files; + +internal class CopyFileInBucketActivity( + IStorageService storageHandler, + ILogger logger) + : IActivity { - internal class CopyFileInBucketActivity( - IStorageService storageHandler, - ILogger logger) - : IActivity + public const string ExecuteEndpointName = "copy-file-in-bucket"; + + public async Task Execute(ExecuteContext executeContext) { - public const string ExecuteEndpointName = "copy-file-in-bucket"; - - public async Task Execute(ExecuteContext executeContext) + try { - try - { - await storageHandler.CopyFileAsync( - executeContext.Arguments.SourceBucket, - executeContext.Arguments.SourceKey, - executeContext.Arguments.DestinationBucket, - executeContext.Arguments.DestinationKey, - executeContext.CancellationToken); + await storageHandler.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) - { - logger.LogError(exception, "Failed to copy {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}", - executeContext.Arguments.SourceBucket, - executeContext.Arguments.SourceKey, - executeContext.Arguments.DestinationBucket, - executeContext.Arguments.DestinationKey); - throw; - } + return executeContext.Completed(new CopyFileInBucketLog( + executeContext.Arguments.DestinationBucket, + executeContext.Arguments.DestinationKey)); } - - public async Task Compensate(CompensateContext compensateContext) + catch (Exception exception) { - try - { - await storageHandler.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); - } + logger.LogError(exception, "Failed to copy {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}", + executeContext.Arguments.SourceBucket, + executeContext.Arguments.SourceKey, + executeContext.Arguments.DestinationBucket, + executeContext.Arguments.DestinationKey); + throw; + } + } + + public async Task Compensate(CompensateContext compensateContext) + { + try + { + await storageHandler.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 53803cf..ec0c953 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromBucketActivity.cs @@ -4,60 +4,59 @@ using Musify.Application.Contracts; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Files +namespace Musify.Infrastructure.MassTransit.Activities.Files; + +internal class DownloadFileFromBucketActivity( + IStorageService storageHandler, + ILogger logger) + : IActivity { - internal class DownloadFileFromBucketActivity( - IStorageService storageHandler, - ILogger logger) - : IActivity + public const string ExecuteEndpointName = "download-file-from-bucket"; + + public async Task Execute(ExecuteContext executeContext) { - public const string ExecuteEndpointName = "download-file-from-bucket"; + var destinationPath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); + ArgumentNullException.ThrowIfNull(destinationPath, nameof(destinationPath)); - public async Task Execute(ExecuteContext executeContext) + try { - var destinationPath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); - ArgumentNullException.ThrowIfNull(destinationPath, nameof(destinationPath)); + using var fileStream = await storageHandler.GetFileAsync( + executeContext.Arguments.Bucket, + executeContext.Arguments.Key, + executeContext.CancellationToken); - try - { - using var fileStream = await storageHandler.GetFileAsync( - executeContext.Arguments.Bucket, - executeContext.Arguments.Key, - executeContext.CancellationToken); + var destinationDirectory = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrWhiteSpace(destinationDirectory)) + Directory.CreateDirectory(destinationDirectory); - var destinationDirectory = Path.GetDirectoryName(destinationPath); - if (!string.IsNullOrWhiteSpace(destinationDirectory)) - Directory.CreateDirectory(destinationDirectory); + if (fileStream.CanSeek) + fileStream.Position = 0; - 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) - { - logger.LogError(exception, "Failed to download file from bucket"); - throw; - } + 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) + catch (Exception 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)); - } + logger.LogError(exception, "Failed to download file from bucket"); + throw; } } -} \ No newline at end of file + + public Task Compensate(CompensateContext compensateContext) + { + 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/DownloadFileFromUrlActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromUrlActivity.cs index 6565766..192e668 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromUrlActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/DownloadFileFromUrlActivity.cs @@ -3,67 +3,66 @@ using Microsoft.Extensions.Logging; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Files +namespace Musify.Infrastructure.MassTransit.Activities.Files; + +internal class DownloadFileFromUrlActivity( + IHttpClientFactory httpClientFactory, + ILogger logger) + : IActivity { - internal class DownloadFileFromUrlActivity( - IHttpClientFactory httpClientFactory, - ILogger logger) - : IActivity + public const string ExecuteEndpointName = "download-file-from-url"; + + public async Task Execute(ExecuteContext executeContext) { - public const string ExecuteEndpointName = "download-file-from-url"; + var destinationPath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); + ArgumentNullException.ThrowIfNull(destinationPath, nameof(destinationPath)); - public async Task Execute(ExecuteContext executeContext) + try { - var destinationPath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); - ArgumentNullException.ThrowIfNull(destinationPath, nameof(destinationPath)); + var httpClient = httpClientFactory.CreateClient(); - try + using var response = await httpClient.GetAsync( + executeContext.Arguments.SourceUrl, + HttpCompletionOption.ResponseHeadersRead, + executeContext.CancellationToken); + response.EnsureSuccessStatusCode(); + + var destinationDirectory = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrWhiteSpace(destinationDirectory)) + Directory.CreateDirectory(destinationDirectory); + + await using (var destinationStream = File.Create(destinationPath)) { - var httpClient = httpClientFactory.CreateClient(); - - using var response = await httpClient.GetAsync( - executeContext.Arguments.SourceUrl, - HttpCompletionOption.ResponseHeadersRead, - executeContext.CancellationToken); - response.EnsureSuccessStatusCode(); - - var destinationDirectory = Path.GetDirectoryName(destinationPath); - if (!string.IsNullOrWhiteSpace(destinationDirectory)) - Directory.CreateDirectory(destinationDirectory); - - await using (var destinationStream = File.Create(destinationPath)) - { - await response.Content.CopyToAsync(destinationStream, executeContext.CancellationToken); - } - - logger.LogInformation("Downloaded {SourceUrl} to {DestinationPath}", - executeContext.Arguments.SourceUrl, destinationPath); - - return executeContext.Completed(new DownloadFileFromUrlLog( - executeContext.Arguments.SourceUrl, - destinationPath)); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to download file from {SourceUrl}", executeContext.Arguments.SourceUrl); - throw; + await response.Content.CopyToAsync(destinationStream, executeContext.CancellationToken); } + + logger.LogInformation("Downloaded {SourceUrl} to {DestinationPath}", + executeContext.Arguments.SourceUrl, destinationPath); + + return executeContext.Completed(new DownloadFileFromUrlLog( + executeContext.Arguments.SourceUrl, + destinationPath)); } - - public Task Compensate(CompensateContext compensateContext) + catch (Exception exception) { - try - { - if (File.Exists(compensateContext.Log.DestinationFilePath)) - 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)); - } + logger.LogError(exception, "Failed to download file from {SourceUrl}", executeContext.Arguments.SourceUrl); + throw; + } + } + + public Task Compensate(CompensateContext compensateContext) + { + try + { + if (File.Exists(compensateContext.Log.DestinationFilePath)) + 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 b148c2b..f76808d 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/RemoveFileFromBucketActivity.cs @@ -1,46 +1,45 @@ -using MassTransit; -using Amazon.S3; -using Microsoft.Extensions.Logging; -using Musify.Infrastructure.MassTransit.Arguments; using System.Net; +using Amazon.S3; +using MassTransit; +using Microsoft.Extensions.Logging; using Musify.Application.Contracts; +using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.Activities.Files +namespace Musify.Infrastructure.MassTransit.Activities.Files; + +internal class RemoveFileFromBucketActivity( + IStorageService storageHandler, + ILogger logger) + : IExecuteActivity { - internal class RemoveFileFromBucketActivity( - IStorageService storageHandler, - ILogger logger) - : IExecuteActivity - { - public const string ExecuteEndpointName = "remove-file-from-bucket"; + public const string ExecuteEndpointName = "remove-file-from-bucket"; - public async Task Execute(ExecuteContext executeContext) + public async Task Execute(ExecuteContext executeContext) + { + try { - try - { - await storageHandler.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(); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to remove {Key} from bucket {Bucket}", - executeContext.Arguments.Key, - executeContext.Arguments.Bucket); - throw; - } + await storageHandler.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(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to remove {Key} from bucket {Bucket}", + executeContext.Arguments.Key, + executeContext.Arguments.Bucket); + throw; } } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs index c23193b..fbf5e19 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/TransferFilesToBucketActivity.cs @@ -4,62 +4,61 @@ using Musify.Application.Contracts; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Files +namespace Musify.Infrastructure.MassTransit.Activities.Files; + +internal class TransferFilesToBucketActivity( + IStorageService storageHandler, + ILogger logger) + : IActivity { - internal class TransferFilesToBucketActivity( - IStorageService storageHandler, - ILogger logger) - : IActivity + public const string ExecuteEndpointName = "transfer-files-to-bucket"; + + public async Task Execute(ExecuteContext executeContext) { - public const string ExecuteEndpointName = "transfer-files-to-bucket"; + var folderPath = executeContext.GetVariable(executeContext.Arguments.SourceDirectoryVariable); + ArgumentNullException.ThrowIfNull(folderPath, nameof(folderPath)); + var destinationKey = executeContext.Arguments.DestinationKey; + ArgumentNullException.ThrowIfNull(destinationKey, nameof(destinationKey)); - public async Task Execute(ExecuteContext executeContext) + try { - var folderPath = executeContext.GetVariable(executeContext.Arguments.SourceDirectoryVariable); - ArgumentNullException.ThrowIfNull(folderPath, nameof(folderPath)); - var destinationKey = executeContext.Arguments.DestinationKey; - ArgumentNullException.ThrowIfNull(destinationKey, nameof(destinationKey)); + var uploadedKeys = await storageHandler.TransferFilesAsync( + folderPath, + executeContext.Arguments.DestinationBucket, + destinationKey, + executeContext.CancellationToken); - try - { - var uploadedKeys = await storageHandler.TransferFilesAsync( - folderPath, - executeContext.Arguments.DestinationBucket, - destinationKey, - executeContext.CancellationToken); - - return executeContext.Completed(new TransferFilesToBucketLog( - executeContext.Arguments.DestinationBucket, - uploadedKeys.ToArray())); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to transfer files from {FolderPath} to {DestinationBucket}", - folderPath, - executeContext.Arguments.DestinationBucket); - throw; - } + return executeContext.Completed(new TransferFilesToBucketLog( + executeContext.Arguments.DestinationBucket, + uploadedKeys.ToArray())); } - - public async Task Compensate(CompensateContext compensateContext) + catch (Exception exception) { - try - { - foreach (var uploadedKey in compensateContext.Log.UploadedKeys) - { - await storageHandler.RemoveFileAsync( - compensateContext.Log.DestinationBucket, - uploadedKey, - compensateContext.CancellationToken); - } + logger.LogError(exception, "Failed to transfer files from {FolderPath} to {DestinationBucket}", + folderPath, + executeContext.Arguments.DestinationBucket); + throw; + } + } - return compensateContext.Compensated(); - } - catch (Exception exception) + public async Task Compensate(CompensateContext compensateContext) + { + try + { + foreach (var uploadedKey in compensateContext.Log.UploadedKeys) { - logger.LogError(exception, "Failed to compensate file transfers to {DestinationBucket}", compensateContext.Log.DestinationBucket); - return compensateContext.Failed(exception); + await storageHandler.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 78029e2..3f8fed5 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Files/UploadFileToBucketActivity.cs @@ -4,58 +4,57 @@ using Musify.Application.Contracts; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Files +namespace Musify.Infrastructure.MassTransit.Activities.Files; + +internal class UploadFileToBucketActivity(IStorageService storageHandler, + ILogger logger) + : IActivity { - internal class UploadFileToBucketActivity(IStorageService storageHandler, - ILogger logger) - : IActivity + public const string ExecuteEndpointName = "upload-file-to-bucket"; + + public async Task Execute(ExecuteContext executeContext) { - public const string ExecuteEndpointName = "upload-file-to-bucket"; + var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.FilePathVariable); + ArgumentNullException.ThrowIfNull(sourceFilePath, nameof(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 async Task Execute(ExecuteContext executeContext) + try { - var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.FilePathVariable); - ArgumentNullException.ThrowIfNull(sourceFilePath, nameof(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('/', '\\'))); - - try - { - await storageHandler.UploadFileAsync( - sourceFilePath, - executeContext.Arguments.DestinationBucket, - destinationKey, - executeContext.CancellationToken); - return executeContext.CompletedWithVariables(new UploadFileToBucketLog( - executeContext.Arguments.DestinationBucket, - destinationKey)); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to upload file to bucket"); - throw; - } + await storageHandler.UploadFileAsync( + sourceFilePath, + executeContext.Arguments.DestinationBucket, + destinationKey, + executeContext.CancellationToken); + return executeContext.CompletedWithVariables(new UploadFileToBucketLog( + executeContext.Arguments.DestinationBucket, + destinationKey)); } - - public async Task Compensate(CompensateContext compensateContext) + catch (Exception exception) { - try - { - await storageHandler.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); - } + logger.LogError(exception, "Failed to upload file to bucket"); + throw; } } -} \ No newline at end of file + + public async Task Compensate(CompensateContext compensateContext) + { + try + { + await storageHandler.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/Pictures/GeneratePictureWorkflowPathsActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs index ce41d3f..37dfe63 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/GeneratePictureWorkflowPathsActivity.cs @@ -2,50 +2,49 @@ using MassTransit; using Microsoft.Extensions.Logging; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.Activities.Pictures +namespace Musify.Infrastructure.MassTransit.Activities.Pictures; + +internal class GeneratePictureWorkflowPathsActivity( + ILogger logger) + : IExecuteActivity { - internal class GeneratePictureWorkflowPathsActivity( - ILogger logger) - : IExecuteActivity + public const string ExecuteEndpointName = "generate-picture-workflow-paths"; + + internal const string ResizedPictureFileExtension = ".webp"; + + public async Task Execute(ExecuteContext executeContext) { - public const string ExecuteEndpointName = "generate-picture-workflow-paths"; - - internal const string ResizedPictureFileExtension = ".webp"; - - public async Task Execute(ExecuteContext executeContext) + try { - try + 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 executeContext.CompletedWithVariables(new Dictionary { - 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 executeContext.CompletedWithVariables(new Dictionary - { - [RoutingSlipVariableNames.Workflow.TemporalDirectory] = workingDirectory, - [RoutingSlipVariableNames.Picture.OriginalFilePath] = sourceFilePath, - [RoutingSlipVariableNames.Picture.SmallResizedFilePath] = smallPictureFilePath, - [RoutingSlipVariableNames.Picture.MediumResizedFilePath] = mediumPictureFilePath, - [RoutingSlipVariableNames.Picture.LargeResizedFilePath] = largePictureFilePath - } ); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to generate picture workflow paths for {SourceKey}", - executeContext.Arguments.SourceKey); - throw; - } + [RoutingSlipVariableNames.Workflow.TemporalDirectory] = workingDirectory, + [RoutingSlipVariableNames.Picture.OriginalFilePath] = sourceFilePath, + [RoutingSlipVariableNames.Picture.SmallResizedFilePath] = smallPictureFilePath, + [RoutingSlipVariableNames.Picture.MediumResizedFilePath] = mediumPictureFilePath, + [RoutingSlipVariableNames.Picture.LargeResizedFilePath] = largePictureFilePath + }); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to generate picture workflow paths for {SourceKey}", + executeContext.Arguments.SourceKey); + throw; } } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs index 78eac7c..dee6d73 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/ResizePictureActivity.cs @@ -4,69 +4,68 @@ using Musify.Application.Contracts; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Pictures +namespace Musify.Infrastructure.MassTransit.Activities.Pictures; + +internal class ResizePictureActivity( + IPictureService pictureHandler, + ILogger logger) + : IActivity { - internal class ResizePictureActivity( - IPictureService pictureHandler, - ILogger logger) - : IActivity + public const string ExecuteEndpointName = "resize-picture"; + + public async Task Execute(ExecuteContext executeContext) { - public const string ExecuteEndpointName = "resize-picture"; + var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.SourceFilePathVariable); + ArgumentNullException.ThrowIfNull(sourceFilePath, nameof(sourceFilePath)); + var destinationFilePath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); + ArgumentNullException.ThrowIfNull(destinationFilePath, nameof(destinationFilePath)); - public async Task Execute(ExecuteContext executeContext) + try { - var sourceFilePath = executeContext.GetVariable(executeContext.Arguments.SourceFilePathVariable); - ArgumentNullException.ThrowIfNull(sourceFilePath, nameof(sourceFilePath)); - var destinationFilePath = executeContext.GetVariable(executeContext.Arguments.DestinationFilePathVariable); - ArgumentNullException.ThrowIfNull(destinationFilePath, nameof(destinationFilePath)); - - try + if (!File.Exists(sourceFilePath)) { - 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)); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to resize picture {SourceFilePath}", sourceFilePath); - throw; + 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)); } - - public Task Compensate(CompensateContext compensateContext) + catch (Exception 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)); - } + logger.LogError(exception, "Failed to resize picture {SourceFilePath}", sourceFilePath); + throw; } } -} \ No newline at end of file + + public Task Compensate(CompensateContext compensateContext) + { + 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/UpdateAlbumPictureActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateAlbumPictureActivity.cs index 53102ea..8d4bf2e 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateAlbumPictureActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateAlbumPictureActivity.cs @@ -6,108 +6,107 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Pictures +namespace Musify.Infrastructure.MassTransit.Activities.Pictures; + +internal class UpdateAlbumPictureActivity( + IDatabase database, + IPublishEndpoint publishEndpoint, + ILogger logger) + : IActivity { - internal class UpdateAlbumPictureActivity( - IDatabase database, - IPublishEndpoint publishEndpoint, - ILogger logger) - : IActivity + public const string ExecuteEndpointName = "update-album-picture"; + + public async Task Execute(ExecuteContext executeContext) { - public const string ExecuteEndpointName = "update-album-picture"; + var smallResizedVariable = executeContext.GetVariable(executeContext.Arguments.SmallPictureVariable); + ArgumentNullException.ThrowIfNull(smallResizedVariable, nameof(smallResizedVariable)); + var mediumResizedVariable = executeContext.GetVariable(executeContext.Arguments.MediumPictureVariable); + ArgumentNullException.ThrowIfNull(mediumResizedVariable, nameof(mediumResizedVariable)); + var largeResizedVariable = executeContext.GetVariable(executeContext.Arguments.LargePictureVariable); + ArgumentNullException.ThrowIfNull(largeResizedVariable, nameof(largeResizedVariable)); - public async Task Execute(ExecuteContext executeContext) + try { - var smallResizedVariable = executeContext.GetVariable(executeContext.Arguments.SmallPictureVariable); - ArgumentNullException.ThrowIfNull(smallResizedVariable, nameof(smallResizedVariable)); - var mediumResizedVariable = executeContext.GetVariable(executeContext.Arguments.MediumPictureVariable); - ArgumentNullException.ThrowIfNull(mediumResizedVariable, nameof(mediumResizedVariable)); - var largeResizedVariable = executeContext.GetVariable(executeContext.Arguments.LargePictureVariable); - ArgumentNullException.ThrowIfNull(largeResizedVariable, nameof(largeResizedVariable)); + var album = await database.Albums.FindAsync( + [executeContext.Arguments.AlbumId], + cancellationToken: executeContext.CancellationToken); - try + if (album == null) { - var album = await database.Albums.FindAsync( - [executeContext.Arguments.AlbumId], - cancellationToken: executeContext.CancellationToken); - - if (album == null) - { - logger.LogWarning("Album {AlbumId} not found", - executeContext.Arguments.AlbumId); - throw new InvalidOperationException($"Album with id {executeContext.Arguments.AlbumId} not found"); - } - - if (album.Pictures == null) - { - logger.LogWarning("Album {AlbumId} has no pending picture upload", - executeContext.Arguments.AlbumId); - throw new InvalidOperationException($"Album with id {executeContext.Arguments.AlbumId} has no pending picture upload"); - } - - var log = new UpdateAlbumPictureLog( - album.Id, - album.Pictures.OriginalName, - album.Pictures.SmallName, - album.Pictures.MediumName, - album.Pictures.LargeName); - - album.Pictures.OriginalName = Path.GetFileName(executeContext.Arguments.OriginalPictureKey); - album.Pictures.SmallName = Path.GetFileName(smallResizedVariable); - album.Pictures.MediumName = Path.GetFileName(mediumResizedVariable); - album.Pictures.LargeName = Path.GetFileName(largeResizedVariable); - - database.Albums.Update(album); - await database.SaveChangesAsync(executeContext.CancellationToken); - - logger.LogInformation("Updated album {AlbumId} pictures", + logger.LogWarning("Album {AlbumId} not found", executeContext.Arguments.AlbumId); - - await publishEndpoint.Publish( - new AlbumPictureProcessed(executeContext.Arguments.AlbumId), - executeContext.CancellationToken); - - return executeContext.Completed(); + throw new InvalidOperationException($"Album with id {executeContext.Arguments.AlbumId} not found"); } - catch (Exception exception) + + if (album.Pictures == null) { - logger.LogError(exception, "Failed to update album {AlbumId} pictures", + logger.LogWarning("Album {AlbumId} has no pending picture upload", executeContext.Arguments.AlbumId); - throw; + throw new InvalidOperationException($"Album with id {executeContext.Arguments.AlbumId} has no pending picture upload"); } + + var log = new UpdateAlbumPictureLog( + album.Id, + album.Pictures.OriginalName, + album.Pictures.SmallName, + album.Pictures.MediumName, + album.Pictures.LargeName); + + album.Pictures.OriginalName = Path.GetFileName(executeContext.Arguments.OriginalPictureKey); + album.Pictures.SmallName = Path.GetFileName(smallResizedVariable); + album.Pictures.MediumName = Path.GetFileName(mediumResizedVariable); + album.Pictures.LargeName = Path.GetFileName(largeResizedVariable); + + database.Albums.Update(album); + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Updated album {AlbumId} pictures", + executeContext.Arguments.AlbumId); + + await publishEndpoint.Publish( + new AlbumPictureProcessed(executeContext.Arguments.AlbumId), + executeContext.CancellationToken); + + return executeContext.Completed(); } - - public async Task Compensate(CompensateContext compensateContext) + catch (Exception exception) { - try + logger.LogError(exception, "Failed to update album {AlbumId} pictures", + executeContext.Arguments.AlbumId); + throw; + } + } + + public async Task Compensate(CompensateContext compensateContext) + { + try + { + var album = await database.Albums.FindAsync( + [compensateContext.Log.AlbumId], + cancellationToken: compensateContext.CancellationToken); + + if (album == null) { - var album = await database.Albums.FindAsync( - [compensateContext.Log.AlbumId], - cancellationToken: compensateContext.CancellationToken); - - if (album == null) - { - return compensateContext.Compensated(); - } - - album.Pictures = new AlbumPictures - { - OriginalName = compensateContext.Log.PreviousOriginalPictureKey, - SmallName = compensateContext.Log.PreviousSmallPictureKey, - MediumName = compensateContext.Log.PreviousMediumPictureKey, - LargeName = compensateContext.Log.PreviousLargePictureKey - }; - - database.Albums.Update(album); - await database.SaveChangesAsync(compensateContext.CancellationToken); - return compensateContext.Compensated(); } - catch (Exception exception) + + album.Pictures = new AlbumPictures { - logger.LogError(exception, "Failed to compensate album {AlbumId} pictures", compensateContext.Log.AlbumId); - return compensateContext.Failed(exception); - } + OriginalName = compensateContext.Log.PreviousOriginalPictureKey, + SmallName = compensateContext.Log.PreviousSmallPictureKey, + MediumName = compensateContext.Log.PreviousMediumPictureKey, + LargeName = compensateContext.Log.PreviousLargePictureKey + }; + + database.Albums.Update(album); + await database.SaveChangesAsync(compensateContext.CancellationToken); + + return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate album {AlbumId} pictures", compensateContext.Log.AlbumId); + return compensateContext.Failed(exception); } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs index 4a4bfe2..36233e6 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdatePlayListPictureActivity.cs @@ -6,108 +6,107 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Pictures +namespace Musify.Infrastructure.MassTransit.Activities.Pictures; + +internal class UpdatePlayListPictureActivity( + IDatabase database, + IPublishEndpoint publishEndpoint, + ILogger logger) + : IActivity { - internal class UpdatePlayListPictureActivity( - IDatabase database, - IPublishEndpoint publishEndpoint, - ILogger logger) - : IActivity + public const string ExecuteEndpointName = "update-play-list-picture"; + + public async Task Execute(ExecuteContext executeContext) { - public const string ExecuteEndpointName = "update-play-list-picture"; + var smallResizedVariable = executeContext.GetVariable(executeContext.Arguments.SmallPictureVariable); + ArgumentNullException.ThrowIfNull(smallResizedVariable, nameof(smallResizedVariable)); + var mediumResizedVariable = executeContext.GetVariable(executeContext.Arguments.MediumPictureVariable); + ArgumentNullException.ThrowIfNull(mediumResizedVariable, nameof(mediumResizedVariable)); + var largeResizedVariable = executeContext.GetVariable(executeContext.Arguments.LargePictureVariable); + ArgumentNullException.ThrowIfNull(largeResizedVariable, nameof(largeResizedVariable)); - public async Task Execute(ExecuteContext executeContext) + try { - var smallResizedVariable = executeContext.GetVariable(executeContext.Arguments.SmallPictureVariable); - ArgumentNullException.ThrowIfNull(smallResizedVariable, nameof(smallResizedVariable)); - var mediumResizedVariable = executeContext.GetVariable(executeContext.Arguments.MediumPictureVariable); - ArgumentNullException.ThrowIfNull(mediumResizedVariable, nameof(mediumResizedVariable)); - var largeResizedVariable = executeContext.GetVariable(executeContext.Arguments.LargePictureVariable); - ArgumentNullException.ThrowIfNull(largeResizedVariable, nameof(largeResizedVariable)); + var playList = await database.PlayLists.FindAsync( + [executeContext.Arguments.PlayListId], + cancellationToken: executeContext.CancellationToken); - try + if (playList == null) { - var playList = await database.PlayLists.FindAsync( - [executeContext.Arguments.PlayListId], - cancellationToken: executeContext.CancellationToken); - - if (playList == null) - { - logger.LogWarning("Playlist {PlayListId} not found", - executeContext.Arguments.PlayListId); - throw new InvalidOperationException($"PlayList with id {executeContext.Arguments.PlayListId} not found"); - } - - if (playList.Pictures == null) - { - logger.LogWarning("Playlist {PlayListId} has no pending picture upload", - executeContext.Arguments.PlayListId); - throw new InvalidOperationException($"PlayList with id {executeContext.Arguments.PlayListId} has no pending picture upload"); - } - - var log = new UpdatePlayListPictureLog( - playList.Id, - playList.Pictures.OriginalName, - playList.Pictures.SmallName, - playList.Pictures.MediumName, - playList.Pictures.LargeName); - - playList.Pictures.OriginalName = Path.GetFileName(executeContext.Arguments.OriginalPictureKey); - playList.Pictures.SmallName = Path.GetFileName(smallResizedVariable); - playList.Pictures.MediumName = Path.GetFileName(mediumResizedVariable); - playList.Pictures.LargeName = Path.GetFileName(largeResizedVariable); - - database.PlayLists.Update(playList); - await database.SaveChangesAsync(executeContext.CancellationToken); - - logger.LogInformation("Updated playlist {PlayListId} pictures", + logger.LogWarning("Playlist {PlayListId} not found", executeContext.Arguments.PlayListId); - - await publishEndpoint.Publish( - new PlayListPictureProcessed(executeContext.Arguments.PlayListId), - executeContext.CancellationToken); - - return executeContext.Completed(); + throw new InvalidOperationException($"PlayList with id {executeContext.Arguments.PlayListId} not found"); } - catch (Exception exception) + + if (playList.Pictures == null) { - logger.LogError(exception, "Failed to update playlist {PlayListId} pictures", + logger.LogWarning("Playlist {PlayListId} has no pending picture upload", executeContext.Arguments.PlayListId); - throw; + throw new InvalidOperationException($"PlayList with id {executeContext.Arguments.PlayListId} has no pending picture upload"); } + + var log = new UpdatePlayListPictureLog( + playList.Id, + playList.Pictures.OriginalName, + playList.Pictures.SmallName, + playList.Pictures.MediumName, + playList.Pictures.LargeName); + + playList.Pictures.OriginalName = Path.GetFileName(executeContext.Arguments.OriginalPictureKey); + playList.Pictures.SmallName = Path.GetFileName(smallResizedVariable); + playList.Pictures.MediumName = Path.GetFileName(mediumResizedVariable); + playList.Pictures.LargeName = Path.GetFileName(largeResizedVariable); + + database.PlayLists.Update(playList); + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Updated playlist {PlayListId} pictures", + executeContext.Arguments.PlayListId); + + await publishEndpoint.Publish( + new PlayListPictureProcessed(executeContext.Arguments.PlayListId), + executeContext.CancellationToken); + + return executeContext.Completed(); } - - public async Task Compensate(CompensateContext compensateContext) + catch (Exception exception) { - try - { - var playList = await database.PlayLists.FindAsync( - [compensateContext.Log.PlayListId], - cancellationToken: compensateContext.CancellationToken); - - if (playList == null) - { - return compensateContext.Compensated(); - } - - playList.Pictures = new PlayListPictures - { - OriginalName = compensateContext.Log.PreviousOriginalPictureKey, - SmallName = compensateContext.Log.PreviousSmallPictureKey, - MediumName = compensateContext.Log.PreviousMediumPictureKey, - LargeName = compensateContext.Log.PreviousLargePictureKey - }; - - database.PlayLists.Update(playList); - await database.SaveChangesAsync(compensateContext.CancellationToken); - - return compensateContext.Compensated(); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to compensate playlist {PlayListId} pictures", compensateContext.Log.PlayListId); - return compensateContext.Failed(exception); - } + logger.LogError(exception, "Failed to update playlist {PlayListId} pictures", + executeContext.Arguments.PlayListId); + throw; } } -} \ No newline at end of file + + public async Task Compensate(CompensateContext compensateContext) + { + try + { + var playList = await database.PlayLists.FindAsync( + [compensateContext.Log.PlayListId], + cancellationToken: compensateContext.CancellationToken); + + if (playList == null) + { + return compensateContext.Compensated(); + } + + playList.Pictures = new PlayListPictures + { + OriginalName = compensateContext.Log.PreviousOriginalPictureKey, + SmallName = compensateContext.Log.PreviousSmallPictureKey, + MediumName = compensateContext.Log.PreviousMediumPictureKey, + LargeName = compensateContext.Log.PreviousLargePictureKey + }; + + database.PlayLists.Update(playList); + await database.SaveChangesAsync(compensateContext.CancellationToken); + + return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate playlist {PlayListId} pictures", compensateContext.Log.PlayListId); + return compensateContext.Failed(exception); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs index 2f240c8..ab1999a 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Pictures/UpdateTrackPictureActivity.cs @@ -6,98 +6,97 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Logs; -namespace Musify.Infrastructure.MassTransit.Activities.Pictures +namespace Musify.Infrastructure.MassTransit.Activities.Pictures; + +internal class UpdateTrackPictureActivity( + IDatabase database, + IPublishEndpoint publishEndpoint, + ILogger logger) + : IActivity { - internal class UpdateTrackPictureActivity( - IDatabase database, - IPublishEndpoint publishEndpoint, - ILogger logger) - : IActivity + public const string ExecuteEndpointName = "update-track-picture"; + + public async Task Execute(ExecuteContext executeContext) { - public const string ExecuteEndpointName = "update-track-picture"; + var smallResizedVariable = executeContext.GetVariable(executeContext.Arguments.SmallPictureVariable); + ArgumentNullException.ThrowIfNull(smallResizedVariable, nameof(smallResizedVariable)); + var mediumResizedVariable = executeContext.GetVariable(executeContext.Arguments.MediumPictureVariable); + ArgumentNullException.ThrowIfNull(mediumResizedVariable, nameof(mediumResizedVariable)); + var largeResizedVariable = executeContext.GetVariable(executeContext.Arguments.LargePictureVariable); + ArgumentNullException.ThrowIfNull(largeResizedVariable, nameof(largeResizedVariable)); - public async Task Execute(ExecuteContext executeContext) + try { - var smallResizedVariable = executeContext.GetVariable(executeContext.Arguments.SmallPictureVariable); - ArgumentNullException.ThrowIfNull(smallResizedVariable, nameof(smallResizedVariable)); - var mediumResizedVariable = executeContext.GetVariable(executeContext.Arguments.MediumPictureVariable); - ArgumentNullException.ThrowIfNull(mediumResizedVariable, nameof(mediumResizedVariable)); - var largeResizedVariable = executeContext.GetVariable(executeContext.Arguments.LargePictureVariable); - ArgumentNullException.ThrowIfNull(largeResizedVariable, nameof(largeResizedVariable)); + var track = await database.Tracks.FindAsync( + [executeContext.Arguments.TrackId], + cancellationToken: executeContext.CancellationToken); - try + if (track == null) { - var track = await database.Tracks.FindAsync( - [executeContext.Arguments.TrackId], - cancellationToken: executeContext.CancellationToken); - - if (track == null) - { - logger.LogWarning("Track {TrackId} not found", - executeContext.Arguments.TrackId); - throw new InvalidOperationException($"Track with id {executeContext.Arguments.TrackId} not found"); - } - - var log = new UpdateTrackPictureLog( - track.Id, - track.Pictures.OriginalName, - track.Pictures.SmallName, - track.Pictures.MediumName, - track.Pictures.LargeName); - - track.Pictures.OriginalName = Path.GetFileName(executeContext.Arguments.OriginalPictureKey); - track.Pictures.SmallName = Path.GetFileName(smallResizedVariable); - track.Pictures.MediumName = Path.GetFileName(mediumResizedVariable); - track.Pictures.LargeName = Path.GetFileName(largeResizedVariable); - track.Pictures.ProcessingStatus = ProcessingStatus.Completed; - - database.Tracks.Update(track); - await database.SaveChangesAsync(executeContext.CancellationToken); - - logger.LogInformation("Updated track {TrackId} pictures", + logger.LogWarning("Track {TrackId} not found", executeContext.Arguments.TrackId); - - await publishEndpoint.Publish( - new TrackPictureProcessed(executeContext.Arguments.TrackId), - executeContext.CancellationToken); - - return executeContext.Completed(); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to update track {TrackId} pictures", - executeContext.Arguments.TrackId); - throw; + throw new InvalidOperationException($"Track with id {executeContext.Arguments.TrackId} not found"); } + + var log = new UpdateTrackPictureLog( + track.Id, + track.Pictures.OriginalName, + track.Pictures.SmallName, + track.Pictures.MediumName, + track.Pictures.LargeName); + + track.Pictures.OriginalName = Path.GetFileName(executeContext.Arguments.OriginalPictureKey); + track.Pictures.SmallName = Path.GetFileName(smallResizedVariable); + track.Pictures.MediumName = Path.GetFileName(mediumResizedVariable); + track.Pictures.LargeName = Path.GetFileName(largeResizedVariable); + track.Pictures.ProcessingStatus = ProcessingStatus.Completed; + + database.Tracks.Update(track); + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Updated track {TrackId} pictures", + executeContext.Arguments.TrackId); + + await publishEndpoint.Publish( + new TrackPictureProcessed(executeContext.Arguments.TrackId), + executeContext.CancellationToken); + + return executeContext.Completed(); } - - public async Task Compensate(CompensateContext compensateContext) + catch (Exception exception) { - try - { - var track = await database.Tracks.FindAsync( - [compensateContext.Log.TrackId], - cancellationToken: compensateContext.CancellationToken); - - if (track == null) - return compensateContext.Compensated(); - - track.Pictures.OriginalName = compensateContext.Log.PreviousOriginalPictureKey; - track.Pictures.SmallName = compensateContext.Log.PreviousSmallPictureKey; - track.Pictures.MediumName = compensateContext.Log.PreviousMediumPictureKey; - track.Pictures.LargeName = compensateContext.Log.PreviousLargePictureKey; - track.Pictures.ProcessingStatus = ProcessingStatus.Failed; - - database.Tracks.Update(track); - await database.SaveChangesAsync(compensateContext.CancellationToken); - - return compensateContext.Compensated(); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to compensate track {TrackId} pictures", compensateContext.Log.TrackId); - return compensateContext.Failed(exception); - } + logger.LogError(exception, "Failed to update track {TrackId} pictures", + executeContext.Arguments.TrackId); + throw; } } -} \ No newline at end of file + + public async Task Compensate(CompensateContext compensateContext) + { + try + { + var track = await database.Tracks.FindAsync( + [compensateContext.Log.TrackId], + cancellationToken: compensateContext.CancellationToken); + + if (track == null) + return compensateContext.Compensated(); + + track.Pictures.OriginalName = compensateContext.Log.PreviousOriginalPictureKey; + track.Pictures.SmallName = compensateContext.Log.PreviousSmallPictureKey; + track.Pictures.MediumName = compensateContext.Log.PreviousMediumPictureKey; + track.Pictures.LargeName = compensateContext.Log.PreviousLargePictureKey; + track.Pictures.ProcessingStatus = ProcessingStatus.Failed; + + database.Tracks.Update(track); + await database.SaveChangesAsync(compensateContext.CancellationToken); + + return compensateContext.Compensated(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to compensate track {TrackId} pictures", compensateContext.Log.TrackId); + return compensateContext.Failed(exception); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbActivity.cs index 1c589e8..7ef578a 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/DeletePlayListFromDbActivity.cs @@ -4,33 +4,32 @@ using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.Activities.PlayLists +namespace Musify.Infrastructure.MassTransit.Activities.PlayLists; + +internal class DeletePlayListFromDbActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity { - internal class DeletePlayListFromDbActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity + public const string ExecuteEndpointName = "delete-play-list-from-db"; + + public async Task Execute(ExecuteContext executeContext) { - public const string ExecuteEndpointName = "delete-play-list-from-db"; + var playList = await database.PlayLists + .SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.PlayListId, executeContext.CancellationToken); - public async Task Execute(ExecuteContext executeContext) + if (playList == null) { - var playList = await database.PlayLists - .SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.PlayListId, executeContext.CancellationToken); - - if (playList == null) - { - logger.LogInformation( - "PlayList {PlayListId} not found while deleting from DB, skipping", - executeContext.Arguments.PlayListId); - return executeContext.Completed(); - } - - database.PlayLists.Remove(playList); - await database.SaveChangesAsync(executeContext.CancellationToken); - - logger.LogInformation("Deleted playlist {PlayListId} from DB", playList.Id); + logger.LogInformation( + "PlayList {PlayListId} not found while deleting from DB, skipping", + executeContext.Arguments.PlayListId); return executeContext.Completed(); } + + database.PlayLists.Remove(playList); + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Deleted playlist {PlayListId} from DB", playList.Id); + return executeContext.Completed(); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsFailedActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsFailedActivity.cs index f534204..fa15beb 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsFailedActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsFailedActivity.cs @@ -5,28 +5,27 @@ using Musify.Application.Contracts; using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.Activities.PlayLists +namespace Musify.Infrastructure.MassTransit.Activities.PlayLists; + +internal class MarkPlayListAsFailedActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity { - internal class MarkPlayListAsFailedActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity + public const string ExecuteEndpointName = "mark-play-list-as-failed"; + + public async Task Execute(ExecuteContext executeContext) { - public const string ExecuteEndpointName = "mark-play-list-as-failed"; + var playList = await database.PlayLists + .SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.PlayListId, executeContext.CancellationToken); - public async Task Execute(ExecuteContext executeContext) + if (playList != null) { - var playList = await database.PlayLists - .SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.PlayListId, executeContext.CancellationToken); - - if (playList != null) - { - playList.LifeCycleStatus = LifeCycleStatus.Failed; - await database.SaveChangesAsync(executeContext.CancellationToken); - logger.LogInformation("Marked playlist {PlayListId} as failed", executeContext.Arguments.PlayListId); - } - - return executeContext.Completed(); + playList.LifeCycleStatus = LifeCycleStatus.Failed; + await database.SaveChangesAsync(executeContext.CancellationToken); + logger.LogInformation("Marked playlist {PlayListId} as failed", executeContext.Arguments.PlayListId); } + + return executeContext.Completed(); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingActivity.cs index bac3433..52f0afb 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/MarkPlayListAsRemovingActivity.cs @@ -1,37 +1,36 @@ using MassTransit; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using Musify.Infrastructure.MassTransit.Arguments; -using Musify.Domain.ValueObjects; using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.Activities.PlayLists +namespace Musify.Infrastructure.MassTransit.Activities.PlayLists; + +internal class MarkPlayListAsRemovingActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity { - internal class MarkPlayListAsRemovingActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity + public const string ExecuteEndpointName = "mark-play-list-as-removing"; + + public async Task Execute(ExecuteContext executeContext) { - public const string ExecuteEndpointName = "mark-play-list-as-removing"; + var playList = await database.PlayLists + .SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.PlayListId, executeContext.CancellationToken); - public async Task Execute(ExecuteContext executeContext) + if (playList == null) { - var playList = await database.PlayLists - .SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.PlayListId, executeContext.CancellationToken); - - if (playList == null) - { - logger.LogInformation( - "PlayList {PlayListId} not found while marking as removing, skipping", - executeContext.Arguments.PlayListId); - return executeContext.Completed(); - } - - playList.LifeCycleStatus = LifeCycleStatus.Removing; - await database.SaveChangesAsync(executeContext.CancellationToken); - - logger.LogInformation("Marked playlist {PlayListId} as removing", playList.Id); + logger.LogInformation( + "PlayList {PlayListId} not found while marking as removing, skipping", + executeContext.Arguments.PlayListId); return executeContext.Completed(); } + + playList.LifeCycleStatus = LifeCycleStatus.Removing; + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Marked playlist {PlayListId} as removing", playList.Id); + return executeContext.Completed(); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs index 4112cbb..530774d 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/PlayLists/PublishPlayListPictureProcessingEventActivity.cs @@ -3,32 +3,31 @@ using Microsoft.Extensions.Logging; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.Activities.PlayLists +namespace Musify.Infrastructure.MassTransit.Activities.PlayLists; + +internal class PublishPlayListPictureProcessingEventActivity( + IPublishEndpoint publishEndpoint, + ILogger logger) + : IExecuteActivity { - internal class PublishPlayListPictureProcessingEventActivity( - IPublishEndpoint publishEndpoint, - ILogger logger) - : IExecuteActivity + public const string ExecuteEndpointName = "publish-play-list-picture-processing-event"; + + public async Task Execute(ExecuteContext executeContext) { - public const string ExecuteEndpointName = "publish-play-list-picture-processing-event"; + var arguments = executeContext.Arguments; - public async Task Execute(ExecuteContext executeContext) - { - var arguments = executeContext.Arguments; + await publishEndpoint.Publish( + new UpdatePlayListPictureEvent( + arguments.PlayListId, + arguments.Bucket, + arguments.FinalPictureKey, + arguments.Small, + arguments.Medium, + arguments.Large), + executeContext.CancellationToken); - await publishEndpoint.Publish( - new UpdatePlayListPictureEvent( - arguments.PlayListId, - arguments.Bucket, - arguments.FinalPictureKey, - arguments.Small, - arguments.Medium, - arguments.Large), - executeContext.CancellationToken); + logger.LogInformation("Published picture processing event for playlist {PlayListId}", arguments.PlayListId); - logger.LogInformation("Published picture processing event for playlist {PlayListId}", arguments.PlayListId); - - return executeContext.Completed(); - } + return executeContext.Completed(); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbActivity.cs index cb1b0e0..df3592e 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/DeleteTrackFromDbActivity.cs @@ -4,33 +4,32 @@ using Microsoft.Extensions.Logging; using Musify.Application.Contracts; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.Activities.Tracks +namespace Musify.Infrastructure.MassTransit.Activities.Tracks; + +internal class DeleteTrackFromDbActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity { - internal class DeleteTrackFromDbActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity + public const string ExecuteEndpointName = "delete-track-from-db"; + + public async Task Execute(ExecuteContext executeContext) { - public const string ExecuteEndpointName = "delete-track-from-db"; + var track = await database.Tracks + .SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); - public async Task Execute(ExecuteContext executeContext) + if (track == null) { - var track = await database.Tracks - .SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); - - if (track == null) - { - logger.LogInformation( - "Track {TrackId} not found while deleting from DB, skipping", - executeContext.Arguments.TrackId); - return executeContext.Completed(); - } - - database.Tracks.Remove(track); - await database.SaveChangesAsync(executeContext.CancellationToken); - - logger.LogInformation("Deleted track {TrackId} from DB", track.Id); + logger.LogInformation( + "Track {TrackId} not found while deleting from DB, skipping", + executeContext.Arguments.TrackId); return executeContext.Completed(); } + + database.Tracks.Remove(track); + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Deleted track {TrackId} from DB", track.Id); + return executeContext.Completed(); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsFailedActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsFailedActivity.cs index 46f2a69..4d73742 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsFailedActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsFailedActivity.cs @@ -5,28 +5,27 @@ using Musify.Application.Contracts; using Musify.Domain.ValueObjects; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.Activities.Tracks +namespace Musify.Infrastructure.MassTransit.Activities.Tracks; + +internal class MarkTrackAsFailedActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity { - internal class MarkTrackAsFailedActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity + public const string ExecuteEndpointName = "mark-track-as-failed"; + + public async Task Execute(ExecuteContext executeContext) { - public const string ExecuteEndpointName = "mark-track-as-failed"; + var track = await database.Tracks + .SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); - public async Task Execute(ExecuteContext executeContext) + if (track != null) { - var track = await database.Tracks - .SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); - - if (track != null) - { - track.LifeCycleStatus = LifeCycleStatus.Failed; - await database.SaveChangesAsync(executeContext.CancellationToken); - logger.LogInformation("Marked track {TrackId} as failed", executeContext.Arguments.TrackId); - } - - return executeContext.Completed(); + track.LifeCycleStatus = LifeCycleStatus.Failed; + await database.SaveChangesAsync(executeContext.CancellationToken); + logger.LogInformation("Marked track {TrackId} as failed", executeContext.Arguments.TrackId); } + + return executeContext.Completed(); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingActivity.cs b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingActivity.cs index d5b9a10..1b22711 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/MarkTrackAsRemovingActivity.cs @@ -1,37 +1,36 @@ using MassTransit; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using Musify.Infrastructure.MassTransit.Arguments; -using Musify.Domain.ValueObjects; using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.Activities.Tracks +namespace Musify.Infrastructure.MassTransit.Activities.Tracks; + +internal class MarkTrackAsRemovingActivity( + IDatabase database, + ILogger logger) + : IExecuteActivity { - internal class MarkTrackAsRemovingActivity( - IDatabase database, - ILogger logger) - : IExecuteActivity + public const string ExecuteEndpointName = "mark-track-as-removing"; + + public async Task Execute(ExecuteContext executeContext) { - public const string ExecuteEndpointName = "mark-track-as-removing"; + var track = await database.Tracks + .SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); - public async Task Execute(ExecuteContext executeContext) + if (track == null) { - var track = await database.Tracks - .SingleOrDefaultAsync(t => t.Id == executeContext.Arguments.TrackId, executeContext.CancellationToken); - - if (track == null) - { - logger.LogInformation( - "Track {TrackId} not found while marking as removing, skipping", - executeContext.Arguments.TrackId); - return executeContext.Completed(); - } - - track.LifeCycleStatus = LifeCycleStatus.Removing; - await database.SaveChangesAsync(executeContext.CancellationToken); - - logger.LogInformation("Marked track {TrackId} as removing", track.Id); + logger.LogInformation( + "Track {TrackId} not found while marking as removing, skipping", + executeContext.Arguments.TrackId); return executeContext.Completed(); } + + track.LifeCycleStatus = LifeCycleStatus.Removing; + await database.SaveChangesAsync(executeContext.CancellationToken); + + logger.LogInformation("Marked track {TrackId} as removing", track.Id); + 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 a82865c..8e2f03a 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/Tracks/PublishTrackProcessingEventsActivity.cs @@ -3,41 +3,40 @@ using Microsoft.Extensions.Logging; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.Arguments; -namespace Musify.Infrastructure.MassTransit.Activities.Tracks +namespace Musify.Infrastructure.MassTransit.Activities.Tracks; + +internal class PublishTrackProcessingEventsActivity( + IPublishEndpoint publishEndpoint, + ILogger logger) + : IExecuteActivity { - internal class PublishTrackProcessingEventsActivity( - IPublishEndpoint publishEndpoint, - ILogger logger) - : IExecuteActivity + public const string ExecuteEndpointName = "publish-track-processing-events"; + + public async Task Execute(ExecuteContext executeContext) { - public const string ExecuteEndpointName = "publish-track-processing-events"; + var arguments = executeContext.Arguments; - public async Task Execute(ExecuteContext executeContext) - { - var arguments = executeContext.Arguments; + await publishEndpoint.Publish( + new UpdateTrackPictureEvent( + arguments.TrackId, + arguments.Bucket, + arguments.FinalPictureKey, + arguments.Small, + arguments.Medium, + arguments.Large), + executeContext.CancellationToken); - await publishEndpoint.Publish( - new UpdateTrackPictureEvent( - arguments.TrackId, - arguments.Bucket, - arguments.FinalPictureKey, - arguments.Small, - arguments.Medium, - arguments.Large), - executeContext.CancellationToken); + await publishEndpoint.Publish( + new UpdateTrackAudioEvent( + arguments.TrackId, + arguments.Bucket, + arguments.FinalAudioKey, + arguments.Bucket, + arguments.AudioDestinationFolderKey), + executeContext.CancellationToken); - await publishEndpoint.Publish( - new UpdateTrackAudioEvent( - arguments.TrackId, - arguments.Bucket, - arguments.FinalAudioKey, - arguments.Bucket, - arguments.AudioDestinationFolderKey), - executeContext.CancellationToken); + logger.LogInformation("Published processing events for track {TrackId}", arguments.TrackId); - logger.LogInformation("Published processing events for track {TrackId}", arguments.TrackId); - - return executeContext.Completed(); - } + 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 1582d32..24d8aa9 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Activities/UploadIntents/ConsumeUploadIntentsActivity.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Activities/UploadIntents/ConsumeUploadIntentsActivity.cs @@ -6,57 +6,56 @@ 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 - { - public const string ExecuteEndpointName = "consume-upload-intents"; +namespace Musify.Infrastructure.MassTransit.Activities.UploadIntents; - public async Task Execute(ExecuteContext executeContext) +internal class ConsumeUploadIntentsActivity( + IDatabase database, + ILogger logger) + : IActivity +{ + public const string ExecuteEndpointName = "consume-upload-intents"; + + public async Task Execute(ExecuteContext executeContext) + { + var intentIds = executeContext.Arguments.IntentIds; + + 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 { - var intentIds = executeContext.Arguments.IntentIds; + var intentIds = compensateContext.Log.IntentIds; var intents = await database.UploadIntents .Where(i => intentIds.Contains(i.Id)) - .ToListAsync(executeContext.CancellationToken); + .ToListAsync(compensateContext.CancellationToken); foreach (var intent in intents) - intent.Status = UploadIntentStatus.Consumed; + intent.Status = UploadIntentStatus.Issued; - await database.SaveChangesAsync(executeContext.CancellationToken); + await database.SaveChangesAsync(compensateContext.CancellationToken); - logger.LogInformation("Marked {Count} upload intent(s) as consumed", intents.Count); + logger.LogInformation("Reverted {Count} upload intent(s) back to Issued", intents.Count); - return executeContext.Completed(new ConsumeUploadIntentsLog(intentIds)); + return compensateContext.Compensated(); } - - public async Task Compensate(CompensateContext compensateContext) + catch (Exception 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); - } + 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 70cd9c2..d133085 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/ConsumeUploadIntentsArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/ConsumeUploadIntentsArguments.cs @@ -1,5 +1,4 @@ -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 ade186a..d73c5ea 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/CopyFileInBucketArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/CopyFileInBucketArguments.cs @@ -1,8 +1,7 @@ -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/DeletePlayListFromDbArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeletePlayListFromDbArguments.cs index b455ed9..d5b85f3 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeletePlayListFromDbArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeletePlayListFromDbArguments.cs @@ -1,5 +1,4 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - internal record DeletePlayListFromDbArguments( - Guid PlayListId); -} +namespace Musify.Infrastructure.MassTransit.Arguments; + +internal record DeletePlayListFromDbArguments( + Guid PlayListId); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteTrackFromDbArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteTrackFromDbArguments.cs index 982e1ae..d3c9e6d 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteTrackFromDbArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DeleteTrackFromDbArguments.cs @@ -1,5 +1,4 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - internal record DeleteTrackFromDbArguments( - Guid TrackId); -} +namespace Musify.Infrastructure.MassTransit.Arguments; + +internal record DeleteTrackFromDbArguments( + Guid TrackId); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromBucketArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromBucketArguments.cs index 1579a91..853ba02 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromBucketArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromBucketArguments.cs @@ -1,7 +1,6 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - public record DownloadFileFromBucketArguments( - string Bucket, - string Key, - string DestinationFilePathVariable); -} \ No newline at end of file +namespace Musify.Infrastructure.MassTransit.Arguments; + +public record DownloadFileFromBucketArguments( + string Bucket, + string Key, + string DestinationFilePathVariable); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromUrlArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromUrlArguments.cs index 5d7422b..4784e12 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromUrlArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/DownloadFileFromUrlArguments.cs @@ -1,6 +1,5 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - public record DownloadFileFromUrlArguments( - string SourceUrl, - string DestinationFilePathVariable); -} +namespace Musify.Infrastructure.MassTransit.Arguments; + +public record DownloadFileFromUrlArguments( + string SourceUrl, + string DestinationFilePathVariable); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GenerateAudioWorkflowPathsArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GenerateAudioWorkflowPathsArguments.cs index 0786562..885fe7f 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GenerateAudioWorkflowPathsArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GenerateAudioWorkflowPathsArguments.cs @@ -1,7 +1,6 @@ -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 330a3b8..65be2e6 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GeneratePictureWorkflowPathsArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/GeneratePictureWorkflowPathsArguments.cs @@ -1,6 +1,5 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - public record GeneratePictureWorkflowPathsArguments( - string TemporaryRootDirectory, - string SourceKey); -} \ No newline at end of file +namespace Musify.Infrastructure.MassTransit.Arguments; + +public record GeneratePictureWorkflowPathsArguments( + string TemporaryRootDirectory, + string SourceKey); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsFailedArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsFailedArguments.cs index 1c9cf0e..36dd356 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsFailedArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkAlbumAsFailedArguments.cs @@ -1,4 +1,3 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - public record MarkAlbumAsFailedArguments(Guid AlbumId); -} +namespace Musify.Infrastructure.MassTransit.Arguments; + +public record MarkAlbumAsFailedArguments(Guid AlbumId); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsFailedArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsFailedArguments.cs index 386c9ec..e5e0436 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsFailedArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsFailedArguments.cs @@ -1,4 +1,3 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - public record MarkPlayListAsFailedArguments(Guid PlayListId); -} +namespace Musify.Infrastructure.MassTransit.Arguments; + +public record MarkPlayListAsFailedArguments(Guid PlayListId); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsRemovingArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsRemovingArguments.cs index 8cd63f7..5165864 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsRemovingArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkPlayListAsRemovingArguments.cs @@ -1,5 +1,4 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - internal record MarkPlayListAsRemovingArguments( - Guid PlayListId); -} +namespace Musify.Infrastructure.MassTransit.Arguments; + +internal record MarkPlayListAsRemovingArguments( + Guid PlayListId); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsFailedArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsFailedArguments.cs index 659f9e0..ae3be70 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsFailedArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsFailedArguments.cs @@ -1,4 +1,3 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - public record MarkTrackAsFailedArguments(Guid TrackId); -} +namespace Musify.Infrastructure.MassTransit.Arguments; + +public record MarkTrackAsFailedArguments(Guid TrackId); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsRemovingArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsRemovingArguments.cs index 5731258..96c25a7 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsRemovingArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/MarkTrackAsRemovingArguments.cs @@ -1,5 +1,4 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - internal record MarkTrackAsRemovingArguments( - Guid TrackId); -} +namespace Musify.Infrastructure.MassTransit.Arguments; + +internal record MarkTrackAsRemovingArguments( + Guid TrackId); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs index f484264..6600b1f 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishAlbumPictureProcessingEventArguments.cs @@ -1,12 +1,11 @@ using Musify.Application.Shared; -namespace Musify.Infrastructure.MassTransit.Arguments -{ - public record PublishAlbumPictureProcessingEventArguments( - Guid AlbumId, - string Bucket, - string FinalPictureKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); -} +namespace Musify.Infrastructure.MassTransit.Arguments; + +public record PublishAlbumPictureProcessingEventArguments( + Guid AlbumId, + string Bucket, + string FinalPictureKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs index 5721ea8..31000ae 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishPlayListPictureProcessingEventArguments.cs @@ -1,12 +1,11 @@ using Musify.Application.Shared; -namespace Musify.Infrastructure.MassTransit.Arguments -{ - public record PublishPlayListPictureProcessingEventArguments( - Guid PlayListId, - string Bucket, - string FinalPictureKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); -} +namespace Musify.Infrastructure.MassTransit.Arguments; + +public record PublishPlayListPictureProcessingEventArguments( + Guid PlayListId, + string Bucket, + string FinalPictureKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs index a13a5e0..f451b98 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/PublishTrackProcessingEventsArguments.cs @@ -1,14 +1,13 @@ using Musify.Application.Shared; -namespace Musify.Infrastructure.MassTransit.Arguments -{ - public record PublishTrackProcessingEventsArguments( - Guid TrackId, - string Bucket, - string FinalPictureKey, - string FinalAudioKey, - string AudioDestinationFolderKey, - ImageSize Small, - ImageSize Medium, - ImageSize Large); -} +namespace Musify.Infrastructure.MassTransit.Arguments; + +public record PublishTrackProcessingEventsArguments( + Guid TrackId, + string Bucket, + string FinalPictureKey, + string FinalAudioKey, + string AudioDestinationFolderKey, + ImageSize Small, + ImageSize Medium, + ImageSize Large); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/RemoveFileFromBucketArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/RemoveFileFromBucketArguments.cs index 5f3e499..ceb2c64 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/RemoveFileFromBucketArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/RemoveFileFromBucketArguments.cs @@ -1,6 +1,5 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - public record RemoveFileFromBucketArguments( - string Bucket, - string Key); -} \ No newline at end of file +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 022fc7c..04fca58 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/ResizePictureLocalArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/ResizePictureLocalArguments.cs @@ -1,8 +1,7 @@ -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 1533480..9b33211 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TranscodeAudioArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TranscodeAudioArguments.cs @@ -1,7 +1,6 @@ -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 48f804d..f1e34ae 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TransferFilesToBucketArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/TransferFilesToBucketArguments.cs @@ -1,7 +1,6 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - public record TransferFilesToBucketArguments( - string DestinationBucket, - string SourceDirectoryVariable, - string DestinationKey); -} \ No newline at end of file +namespace Musify.Infrastructure.MassTransit.Arguments; + +public record TransferFilesToBucketArguments( + string DestinationBucket, + string SourceDirectoryVariable, + string DestinationKey); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateAlbumPictureArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateAlbumPictureArguments.cs index 6f006f0..6e9129c 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateAlbumPictureArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateAlbumPictureArguments.cs @@ -1,9 +1,8 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - public record UpdateAlbumPictureArguments( - Guid AlbumId, - string OriginalPictureKey, - string SmallPictureVariable, - string MediumPictureVariable, - string LargePictureVariable); -} +namespace Musify.Infrastructure.MassTransit.Arguments; + +public record UpdateAlbumPictureArguments( + Guid AlbumId, + string OriginalPictureKey, + string SmallPictureVariable, + string MediumPictureVariable, + string LargePictureVariable); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePlayListPictureArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePlayListPictureArguments.cs index bb05ee6..7efcc0d 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePlayListPictureArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdatePlayListPictureArguments.cs @@ -1,9 +1,8 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - public record UpdatePlayListPictureArguments( - Guid PlayListId, - string OriginalPictureKey, - string SmallPictureVariable, - string MediumPictureVariable, - string LargePictureVariable); -} \ No newline at end of file +namespace Musify.Infrastructure.MassTransit.Arguments; + +public record UpdatePlayListPictureArguments( + Guid PlayListId, + 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 af0ae38..f9c46e0 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackAudioArguments.cs @@ -1,7 +1,6 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - public record UpdateTrackAudioArguments( - Guid TrackId, - string AudioFolderKey, - string DurationSecondsVariable); -} \ No newline at end of file +namespace Musify.Infrastructure.MassTransit.Arguments; + +public record UpdateTrackAudioArguments( + Guid TrackId, + string AudioFolderKey, + string DurationSecondsVariable); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackPictureArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackPictureArguments.cs index 0c48ac3..37a1ef4 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackPictureArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UpdateTrackPictureArguments.cs @@ -1,9 +1,8 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - public record UpdateTrackPictureArguments( - Guid TrackId, - string OriginalPictureKey, - string SmallPictureVariable, - string MediumPictureVariable, - string LargePictureVariable); -} \ No newline at end of file +namespace Musify.Infrastructure.MassTransit.Arguments; + +public record UpdateTrackPictureArguments( + Guid TrackId, + string OriginalPictureKey, + string SmallPictureVariable, + string MediumPictureVariable, + string LargePictureVariable); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UploadFileToBucketArguments.cs b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UploadFileToBucketArguments.cs index 8fd35f8..66edbc2 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UploadFileToBucketArguments.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Arguments/UploadFileToBucketArguments.cs @@ -1,7 +1,6 @@ -namespace Musify.Infrastructure.MassTransit.Arguments -{ - public record UploadFileToBucketArguments( - string FilePathVariable, - string DestinationBucket, - string DestinationRoute); -} \ No newline at end of file +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 db2c554..d076722 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/AlbumProcessingFailedConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/AlbumProcessingFailedConsumer.cs @@ -6,31 +6,32 @@ using Musify.Infrastructure.MassTransit.Activities.Files; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.RoutingSlip; -namespace Musify.Infrastructure.MassTransit.Consumers +namespace Musify.Infrastructure.MassTransit.Consumers; + +public class AlbumProcessingFailedConsumer(IBus bus) : IConsumer { - public class AlbumProcessingFailedConsumer(IBus bus) : IConsumer + public async Task Consume(ConsumeContext context) { - public async Task Consume(ConsumeContext context) + var message = context.Message; + + var builder = new RoutingSlipBuilder(NewId.NextGuid()); + builder.AddSubscription( + EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + + if (!string.IsNullOrWhiteSpace(message.Bucket) && !string.IsNullOrWhiteSpace(message.PictureKey)) { - var message = context.Message; - - var builder = new RoutingSlipBuilder(NewId.NextGuid()); - builder.AddSubscription( - EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), - RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); - - if (!string.IsNullOrWhiteSpace(message.Bucket) && !string.IsNullOrWhiteSpace(message.PictureKey)) - builder.AddActivity( - ActivityNames.RemoveAlbumOriginalPicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments(message.Bucket, message.PictureKey)); - builder.AddActivity( - ActivityNames.MarkAlbumAsFailed, - EndpointHelper.BuildExecuteActivityUri(MarkAlbumAsFailedActivity.ExecuteEndpointName), - new MarkAlbumAsFailedArguments(message.AlbumId)); - - await bus.Execute(builder.Build()); + ActivityNames.RemoveAlbumOriginalPicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments(message.Bucket, message.PictureKey)); } + + builder.AddActivity( + ActivityNames.MarkAlbumAsFailed, + EndpointHelper.BuildExecuteActivityUri(MarkAlbumAsFailedActivity.ExecuteEndpointName), + new MarkAlbumAsFailedArguments(message.AlbumId)); + + await bus.Execute(builder.Build()); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs index 0c022f8..74c2dce 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateAlbumConsumer.cs @@ -2,21 +2,19 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers +namespace Musify.Infrastructure.MassTransit.Consumers; + +public class CreateAlbumConsumer( + IBus bus, + AlbumPictureSourceRoutingSlipBuilder routingSlipBuilder) + : IConsumer { - public class CreateAlbumConsumer( - IBus bus, - AlbumPictureSourceRoutingSlipBuilder routingSlipBuilder) - : IConsumer + public async Task Consume(ConsumeContext consumeContext) { + var routingSlip = routingSlipBuilder + .Build(consumeContext.Message, consumeContext.CorrelationId) + .Build(); - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + await bus.Execute(routingSlip); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs index 03d4d67..008dd63 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreatePlayListConsumer.cs @@ -2,21 +2,19 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers +namespace Musify.Infrastructure.MassTransit.Consumers; + +public class CreatePlayListConsumer( + IBus bus, + PlayListPictureSourceRoutingSlipBuilder routingSlipBuilder) + : IConsumer { - public class CreatePlayListConsumer( - IBus bus, - PlayListPictureSourceRoutingSlipBuilder routingSlipBuilder) - : IConsumer + public async Task Consume(ConsumeContext consumeContext) { + var routingSlip = routingSlipBuilder + .Build(consumeContext.Message, consumeContext.CorrelationId) + .Build(); - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + await bus.Execute(routingSlip); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs index 058a225..0bdd8d2 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/CreateTrackConsumer.cs @@ -2,21 +2,19 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers +namespace Musify.Infrastructure.MassTransit.Consumers; + +public class CreateTrackConsumer( + IBus bus, + CreateTrackRoutingSlipBuilder routingSlipBuilder) + : IConsumer { - public class CreateTrackConsumer( - IBus bus, - CreateTrackRoutingSlipBuilder routingSlipBuilder) - : IConsumer + public async Task Consume(ConsumeContext consumeContext) { + var routingSlip = routingSlipBuilder + .Build(consumeContext.Message, consumeContext.CorrelationId) + .Build(); - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + await bus.Execute(routingSlip); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs index a361907..fea8f01 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeletePlayListConsumer.cs @@ -2,25 +2,23 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers +namespace Musify.Infrastructure.MassTransit.Consumers; + +public class DeletePlayListConsumer( + IBus bus, + DeletePlayListRoutingSlipBuilder routingSlipBuilder) + : IConsumer { - public class DeletePlayListConsumer( - IBus bus, - DeletePlayListRoutingSlipBuilder routingSlipBuilder) - : IConsumer + public async Task Consume(ConsumeContext consumeContext) { + var routingSlip = (await routingSlipBuilder + .BuildAsync( + consumeContext.Message.PlayListId, + consumeContext.Message.UserId, + consumeContext.CorrelationId, + consumeContext.CancellationToken)) + .Build(); - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = (await routingSlipBuilder - .BuildAsync( - consumeContext.Message.PlayListId, - consumeContext.Message.UserId, - consumeContext.CorrelationId, - consumeContext.CancellationToken)) - .Build(); - - await bus.Execute(routingSlip); - } + await bus.Execute(routingSlip); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs index 4d747fa..9d3aa28 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/DeleteTrackConsumer.cs @@ -2,21 +2,19 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers +namespace Musify.Infrastructure.MassTransit.Consumers; + +public class DeleteTrackConsumer( + IBus bus, + DeleteTrackRoutingSlipBuilder routingSlipBuilder) + : IConsumer { - public class DeleteTrackConsumer( - IBus bus, - DeleteTrackRoutingSlipBuilder routingSlipBuilder) - : IConsumer + public async Task Consume(ConsumeContext consumeContext) { + var routingSlip = (await routingSlipBuilder + .BuildAsync(consumeContext.Message.TrackId, consumeContext.Message.UserId, consumeContext.CorrelationId, consumeContext.CancellationToken)) + .Build(); - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = (await routingSlipBuilder - .BuildAsync(consumeContext.Message.TrackId, consumeContext.Message.UserId, consumeContext.CorrelationId, consumeContext.CancellationToken)) - .Build(); - - await bus.Execute(routingSlip); - } + await bus.Execute(routingSlip); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/EndpointHelper.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/EndpointHelper.cs index 16cc517..945661b 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/EndpointHelper.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/EndpointHelper.cs @@ -1,9 +1,8 @@ -namespace Musify.Infrastructure.MassTransit.Consumers -{ - internal static class EndpointHelper - { - internal static Uri BuildExecuteActivityUri(string endpointName) => new($"queue:{endpointName}_execute"); +namespace Musify.Infrastructure.MassTransit.Consumers; - internal static Uri BuildConsumerUri(string endpointName) => new($"queue:{endpointName}"); - } +internal static class EndpointHelper +{ + internal static Uri BuildExecuteActivityUri(string endpointName) => new($"queue:{endpointName}_execute"); + + internal static Uri BuildConsumerUri(string endpointName) => new($"queue:{endpointName}"); } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/PlayListProcessingFailedConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/PlayListProcessingFailedConsumer.cs index 9f0292d..c06ccf4 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/PlayListProcessingFailedConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/PlayListProcessingFailedConsumer.cs @@ -6,31 +6,32 @@ using Musify.Infrastructure.MassTransit.Activities.PlayLists; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.RoutingSlip; -namespace Musify.Infrastructure.MassTransit.Consumers +namespace Musify.Infrastructure.MassTransit.Consumers; + +public class PlayListProcessingFailedConsumer(IBus bus) : IConsumer { - public class PlayListProcessingFailedConsumer(IBus bus) : IConsumer + public async Task Consume(ConsumeContext context) { - public async Task Consume(ConsumeContext context) + var message = context.Message; + + var builder = new RoutingSlipBuilder(NewId.NextGuid()); + builder.AddSubscription( + EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + + if (!string.IsNullOrWhiteSpace(message.Bucket) && !string.IsNullOrWhiteSpace(message.PictureKey)) { - var message = context.Message; - - var builder = new RoutingSlipBuilder(NewId.NextGuid()); - builder.AddSubscription( - EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), - RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); - - if (!string.IsNullOrWhiteSpace(message.Bucket) && !string.IsNullOrWhiteSpace(message.PictureKey)) - builder.AddActivity( - ActivityNames.RemovePlayListOriginalPicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments(message.Bucket, message.PictureKey)); - builder.AddActivity( - ActivityNames.MarkPlayListAsFailed, - EndpointHelper.BuildExecuteActivityUri(MarkPlayListAsFailedActivity.ExecuteEndpointName), - new MarkPlayListAsFailedArguments(message.PlayListId)); - - await bus.Execute(builder.Build()); + ActivityNames.RemovePlayListOriginalPicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments(message.Bucket, message.PictureKey)); } + + builder.AddActivity( + ActivityNames.MarkPlayListAsFailed, + EndpointHelper.BuildExecuteActivityUri(MarkPlayListAsFailedActivity.ExecuteEndpointName), + new MarkPlayListAsFailedArguments(message.PlayListId)); + + await 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 fd7e8bf..e796383 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/ProcessingSlipFaultConsumer.cs @@ -2,35 +2,36 @@ using MassTransit; using MassTransit.Courier.Contracts; using Musify.Application.Events; -namespace Musify.Infrastructure.MassTransit.Consumers +namespace Musify.Infrastructure.MassTransit.Consumers; + +public class ProcessingSlipFaultConsumer : IConsumer { - public class ProcessingSlipFaultConsumer : IConsumer + public const string QueueName = "processing-slip-fault"; + + public async Task Consume(ConsumeContext context) { - public const string QueueName = "processing-slip-fault"; + var variables = context.Message.Variables; - public async Task Consume(ConsumeContext context) + 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)) { - var variables = context.Message.Variables; + return; + } - 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; - } + 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; } } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs index 582f58a..d04ca6a 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/RoutingSlipCleanUpConsumer.cs @@ -1,48 +1,47 @@ -using MassTransit; +using MassTransit; using MassTransit.Courier.Contracts; using Microsoft.Extensions.Logging; -namespace Musify.Infrastructure.MassTransit.Consumers +namespace Musify.Infrastructure.MassTransit.Consumers; + +public class RoutingSlipCleanUpConsumer( + ILogger logger) : + IConsumer, + IConsumer { - public class RoutingSlipCleanUpConsumer( - ILogger logger) : - IConsumer, - IConsumer + public const string QueueName = "routing-slip-clean-up"; + + public Task Consume(ConsumeContext consumeContext) + => DeleteFolder(consumeContext.Message.Variables); + + public Task Consume(ConsumeContext consumeContext) + => DeleteFolder(consumeContext.Message.Variables); + + private Task DeleteFolder(IDictionary variables) { - public const string QueueName = "routing-slip-clean-up"; - - public Task Consume(ConsumeContext consumeContext) - => DeleteFolder(consumeContext.Message.Variables); - - public Task Consume(ConsumeContext consumeContext) - => DeleteFolder(consumeContext.Message.Variables); - - Task DeleteFolder(IDictionary variables) - { - if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.TemporalDirectory, out var pathObject)) - 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 directoryNotFoundException) - { - logger.LogWarning(directoryNotFoundException, "Temporal directory at path {Path} was not found for deletion", pathString); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to delete temporal directory at path {Path}", pathString); - throw; - } - + if (!variables.TryGetValue(RoutingSlipVariableNames.Workflow.TemporalDirectory, out var pathObject)) 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 directoryNotFoundException) + { + logger.LogWarning(directoryNotFoundException, "Temporal directory at path {Path} was not found for deletion", pathString); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to delete temporal directory at path {Path}", pathString); + throw; + } + + return Task.CompletedTask; } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/TrackProcessingFailedConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/TrackProcessingFailedConsumer.cs index 07bdb55..6a0e645 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/TrackProcessingFailedConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/TrackProcessingFailedConsumer.cs @@ -6,37 +6,40 @@ using Musify.Infrastructure.MassTransit.Activities.Tracks; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.RoutingSlip; -namespace Musify.Infrastructure.MassTransit.Consumers +namespace Musify.Infrastructure.MassTransit.Consumers; + +public class TrackProcessingFailedConsumer(IBus bus) : IConsumer { - public class TrackProcessingFailedConsumer(IBus bus) : IConsumer + public async Task Consume(ConsumeContext context) { - public async Task Consume(ConsumeContext context) + var message = context.Message; + + var builder = new RoutingSlipBuilder(NewId.NextGuid()); + builder.AddSubscription( + EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + + if (!string.IsNullOrWhiteSpace(message.Bucket) && !string.IsNullOrWhiteSpace(message.PictureKey)) { - var message = context.Message; - - var builder = new RoutingSlipBuilder(NewId.NextGuid()); - builder.AddSubscription( - EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), - RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); - - if (!string.IsNullOrWhiteSpace(message.Bucket) && !string.IsNullOrWhiteSpace(message.PictureKey)) - builder.AddActivity( - ActivityNames.RemoveTrackOriginalPicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments(message.Bucket, message.PictureKey)); - - if (!string.IsNullOrWhiteSpace(message.Bucket) && !string.IsNullOrWhiteSpace(message.AudioKey)) - builder.AddActivity( - ActivityNames.RemoveTrackOriginalAudio, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments(message.Bucket, message.AudioKey)); - builder.AddActivity( - ActivityNames.MarkTrackAsFailed, - EndpointHelper.BuildExecuteActivityUri(MarkTrackAsFailedActivity.ExecuteEndpointName), - new MarkTrackAsFailedArguments(message.TrackId)); - - await bus.Execute(builder.Build()); + ActivityNames.RemoveTrackOriginalPicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments(message.Bucket, message.PictureKey)); } + + if (!string.IsNullOrWhiteSpace(message.Bucket) && !string.IsNullOrWhiteSpace(message.AudioKey)) + { + builder.AddActivity( + ActivityNames.RemoveTrackOriginalAudio, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments(message.Bucket, message.AudioKey)); + } + + builder.AddActivity( + ActivityNames.MarkTrackAsFailed, + EndpointHelper.BuildExecuteActivityUri(MarkTrackAsFailedActivity.ExecuteEndpointName), + new MarkTrackAsFailedArguments(message.TrackId)); + + await bus.Execute(builder.Build()); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs index 0a31f11..9e28c7d 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureConsumer.cs @@ -2,21 +2,19 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers +namespace Musify.Infrastructure.MassTransit.Consumers; + +public class UpdateAlbumPictureConsumer( + IBus bus, + PictureWorkflowRoutingSlipBuilder routingSlipBuilder) + : IConsumer { - public class UpdateAlbumPictureConsumer( - IBus bus, - PictureWorkflowRoutingSlipBuilder routingSlipBuilder) - : IConsumer + public async Task Consume(ConsumeContext consumeContext) { + var routingSlip = routingSlipBuilder + .Build(consumeContext.Message, consumeContext.CorrelationId) + .Build(); - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + await bus.Execute(routingSlip); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs index 9ce0dcd..928dd5a 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateAlbumPictureSourceConsumer.cs @@ -2,21 +2,19 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers +namespace Musify.Infrastructure.MassTransit.Consumers; + +public class UpdateAlbumPictureSourceConsumer( + IBus bus, + AlbumPictureSourceRoutingSlipBuilder routingSlipBuilder) + : IConsumer { - public class UpdateAlbumPictureSourceConsumer( - IBus bus, - AlbumPictureSourceRoutingSlipBuilder routingSlipBuilder) - : IConsumer + public async Task Consume(ConsumeContext consumeContext) { + var routingSlip = routingSlipBuilder + .Build(consumeContext.Message, consumeContext.CorrelationId) + .Build(); - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + await bus.Execute(routingSlip); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs index bac0d3d..ba52e76 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureConsumer.cs @@ -1,22 +1,20 @@ -using MassTransit; +using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers +namespace Musify.Infrastructure.MassTransit.Consumers; + +public class UpdatePlayListPictureConsumer( + IBus bus, + PictureWorkflowRoutingSlipBuilder routingSlipBuilder) + : IConsumer { - public class UpdatePlayListPictureConsumer( - IBus bus, - PictureWorkflowRoutingSlipBuilder routingSlipBuilder) - : IConsumer + public async Task Consume(ConsumeContext consumeContext) { + var routingSlip = routingSlipBuilder + .Build(consumeContext.Message, consumeContext.CorrelationId) + .Build(); - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + await bus.Execute(routingSlip); } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs index f29a17e..ea0605b 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdatePlayListPictureSourceConsumer.cs @@ -2,21 +2,19 @@ using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers +namespace Musify.Infrastructure.MassTransit.Consumers; + +public class UpdatePlayListPictureSourceConsumer( + IBus bus, + PlayListPictureSourceRoutingSlipBuilder routingSlipBuilder) + : IConsumer { - public class UpdatePlayListPictureSourceConsumer( - IBus bus, - PlayListPictureSourceRoutingSlipBuilder routingSlipBuilder) - : IConsumer + public async Task Consume(ConsumeContext consumeContext) { + var routingSlip = routingSlipBuilder + .Build(consumeContext.Message, consumeContext.CorrelationId) + .Build(); - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + await bus.Execute(routingSlip); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs index a3ae3c9..828aba8 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackAudioConsumer.cs @@ -1,22 +1,20 @@ -using MassTransit; +using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers +namespace Musify.Infrastructure.MassTransit.Consumers; + +public class UpdateTrackAudioConsumer( + IBus bus, + AudioWorkflowRoutingSlipBuilder routingSlipBuilder) + : IConsumer { - public class UpdateTrackAudioConsumer( - IBus bus, - AudioWorkflowRoutingSlipBuilder routingSlipBuilder) - : IConsumer + public async Task Consume(ConsumeContext consumeContext) { + var routingSlip = routingSlipBuilder + .Build(consumeContext.Message, consumeContext.CorrelationId) + .Build(); - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + await bus.Execute(routingSlip); } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs index 0fe65bc..9f4b1e6 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Consumers/UpdateTrackPictureConsumer.cs @@ -1,22 +1,20 @@ -using MassTransit; +using MassTransit; using Musify.Application.Events; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit.Consumers +namespace Musify.Infrastructure.MassTransit.Consumers; + +public class UpdateTrackPictureConsumer( + IBus bus, + PictureWorkflowRoutingSlipBuilder routingSlipBuilder) + : IConsumer { - public class UpdateTrackPictureConsumer( - IBus bus, - PictureWorkflowRoutingSlipBuilder routingSlipBuilder) - : IConsumer + public async Task Consume(ConsumeContext consumeContext) { + var routingSlip = routingSlipBuilder + .Build(consumeContext.Message, consumeContext.CorrelationId) + .Build(); - public async Task Consume(ConsumeContext consumeContext) - { - var routingSlip = routingSlipBuilder - .Build(consumeContext.Message, consumeContext.CorrelationId) - .Build(); - - await bus.Execute(routingSlip); - } + await bus.Execute(routingSlip); } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Options.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Options.cs index 3255d0a..82ae90f 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Options.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Options.cs @@ -2,23 +2,22 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -namespace Musify.Infrastructure.MassTransit -{ - public static partial class MassTransitDependencyInjection - { - static void RegisterValidatedOptions( - IServiceCollection services, - IConfiguration configuration, - string sectionName) - where TOptions : class - { - services - .AddOptionsWithValidateOnStart() - .Bind(configuration.GetRequiredSection(sectionName)) - .ValidateDataAnnotations(); +namespace Musify.Infrastructure.MassTransit; - services.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService>().Value); - } +public static partial class MassTransitDependencyInjection +{ + private static void RegisterValidatedOptions( + IServiceCollection services, + IConfiguration configuration, + string sectionName) + where TOptions : class + { + services + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(sectionName)) + .ValidateDataAnnotations(); + + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs index cae468b..9759c1b 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.Registration.cs @@ -1,9 +1,9 @@ using MassTransit; using Microsoft.Extensions.DependencyInjection; -using Musify.Infrastructure.MassTransit.Activities.Pictures; using Musify.Infrastructure.MassTransit.Activities.Albums; using Musify.Infrastructure.MassTransit.Activities.Audio; using Musify.Infrastructure.MassTransit.Activities.Files; +using Musify.Infrastructure.MassTransit.Activities.Pictures; using Musify.Infrastructure.MassTransit.Activities.PlayLists; using Musify.Infrastructure.MassTransit.Activities.Tracks; using Musify.Infrastructure.MassTransit.Activities.UploadIntents; @@ -12,66 +12,65 @@ using Musify.Infrastructure.MassTransit.Consumers; using Musify.Infrastructure.MassTransit.Logs; using Musify.Infrastructure.MassTransit.RoutingSlip.Builders; -namespace Musify.Infrastructure.MassTransit +namespace Musify.Infrastructure.MassTransit; + +public static partial class MassTransitDependencyInjection { - public static partial class MassTransitDependencyInjection + private static void RegisterRoutingSlipBuilders(IServiceCollection services) { - static void RegisterRoutingSlipBuilders(IServiceCollection services) - { - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - } + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + } - 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(); + 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.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.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(); + 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 5790c2c..1e6c938 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/DependencyInjection/MassTransitDependencyInjection.cs @@ -6,127 +6,126 @@ using Musify.Infrastructure.Configuration; using Musify.Infrastructure.MassTransit.Sagas; using Musify.Infrastructure.Persistence; -namespace Musify.Infrastructure.MassTransit +namespace Musify.Infrastructure.MassTransit; + +public static partial class MassTransitDependencyInjection { - public static partial class MassTransitDependencyInjection + public static IServiceCollection AddMassTransitClient(this IServiceCollection serviceDescriptors, IConfiguration configuration) { - public static IServiceCollection AddMassTransitClient(this IServiceCollection serviceDescriptors, IConfiguration configuration) + RegisterValidatedOptions( + serviceDescriptors, + configuration, + MassTransitConfiguration.SectionName); + + serviceDescriptors.AddScoped(); + serviceDescriptors.AddMassTransit(options => { - RegisterValidatedOptions( - serviceDescriptors, - configuration, - MassTransitConfiguration.SectionName); - - serviceDescriptors.AddScoped(); - serviceDescriptors.AddMassTransit(options => + options.AddEntityFrameworkOutbox(outbox => { - options.AddEntityFrameworkOutbox(outbox => - { - outbox.UsePostgres(); - outbox.UseBusOutbox(); + outbox.UsePostgres(); + outbox.UseBusOutbox(); - outbox.QueryDelay = TimeSpan.FromSeconds(1); - outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30); - } ); + outbox.QueryDelay = TimeSpan.FromSeconds(1); + outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30); + }); - options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) => - ConfigureRabbitMqHost( - busFactoryConfigurator, - busRegistrationContext.GetRequiredService())); - } ); + options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) => + ConfigureRabbitMqHost( + busFactoryConfigurator, + busRegistrationContext.GetRequiredService())); + }); - return serviceDescriptors; - } - - public static IServiceCollection AddMassTransitConsumers(this IServiceCollection serviceDescriptors, IConfiguration configuration) - { - RegisterValidatedOptions( - serviceDescriptors, - configuration, - MassTransitConfiguration.SectionName); - - RegisterValidatedOptions( - serviceDescriptors, - configuration, - WorkerConfiguration.SectionName); - - RegisterRoutingSlipBuilders(serviceDescriptors); - - serviceDescriptors.AddMassTransit(options => - { - options.AddEntityFrameworkOutbox(outbox => - { - outbox.UsePostgres(); - outbox.UseBusOutbox(); - - outbox.QueryDelay = TimeSpan.FromSeconds(1); - outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30); - } ); - - options.SetKebabCaseEndpointNameFormatter(); - - RegisterConsumersAndActivities(options); - - options.AddSagaStateMachine() - .EntityFrameworkRepository(repository => - { - repository.ConcurrencyMode = ConcurrencyMode.Pessimistic; - repository.ExistingDbContext(); - repository.UsePostgres(); - } ); - - options.AddSagaStateMachine() - .EntityFrameworkRepository(repository => - { - repository.ConcurrencyMode = ConcurrencyMode.Pessimistic; - repository.ExistingDbContext(); - repository.UsePostgres(); - } ); - - options.AddSagaStateMachine() - .EntityFrameworkRepository(repository => - { - repository.ConcurrencyMode = ConcurrencyMode.Pessimistic; - repository.ExistingDbContext(); - repository.UsePostgres(); - } ); - - options.AddConfigureEndpointsCallback((registrationContext, _, endpointConfigurator) => - { - UseStandardRetry(endpointConfigurator); - endpointConfigurator.UseEntityFrameworkOutbox(registrationContext); - } ); - - options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) => - { - ConfigureRabbitMqHost( - busFactoryConfigurator, - busRegistrationContext.GetRequiredService()); - - busFactoryConfigurator.ConfigureEndpoints(busRegistrationContext); - } ); - } ); - - return serviceDescriptors; - } - - static void UseStandardRetry(IReceiveEndpointConfigurator endpointConfigurator) => - endpointConfigurator.UseMessageRetry(retryConfigurator => - retryConfigurator.Exponential( - retryLimit: 5, - minInterval: TimeSpan.FromSeconds(1), - maxInterval: TimeSpan.FromSeconds(30), - intervalDelta: TimeSpan.FromSeconds(2))); - - static void ConfigureRabbitMqHost( - IRabbitMqBusFactoryConfigurator busFactoryConfigurator, - MassTransitConfiguration massTransitConfiguration) - { - busFactoryConfigurator.Host(massTransitConfiguration.Address, options => - { - options.Username(massTransitConfiguration.Username); - options.Password(massTransitConfiguration.Password); - } ); - } + return serviceDescriptors; } -} \ No newline at end of file + + public static IServiceCollection AddMassTransitConsumers(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + RegisterValidatedOptions( + serviceDescriptors, + configuration, + MassTransitConfiguration.SectionName); + + RegisterValidatedOptions( + serviceDescriptors, + configuration, + WorkerConfiguration.SectionName); + + RegisterRoutingSlipBuilders(serviceDescriptors); + + serviceDescriptors.AddMassTransit(options => + { + options.AddEntityFrameworkOutbox(outbox => + { + outbox.UsePostgres(); + outbox.UseBusOutbox(); + + outbox.QueryDelay = TimeSpan.FromSeconds(1); + outbox.DuplicateDetectionWindow = TimeSpan.FromMinutes(30); + }); + + options.SetKebabCaseEndpointNameFormatter(); + + RegisterConsumersAndActivities(options); + + options.AddSagaStateMachine() + .EntityFrameworkRepository(repository => + { + repository.ConcurrencyMode = ConcurrencyMode.Pessimistic; + repository.ExistingDbContext(); + repository.UsePostgres(); + }); + + options.AddSagaStateMachine() + .EntityFrameworkRepository(repository => + { + repository.ConcurrencyMode = ConcurrencyMode.Pessimistic; + repository.ExistingDbContext(); + repository.UsePostgres(); + }); + + options.AddSagaStateMachine() + .EntityFrameworkRepository(repository => + { + repository.ConcurrencyMode = ConcurrencyMode.Pessimistic; + repository.ExistingDbContext(); + repository.UsePostgres(); + }); + + options.AddConfigureEndpointsCallback((registrationContext, _, endpointConfigurator) => + { + UseStandardRetry(endpointConfigurator); + endpointConfigurator.UseEntityFrameworkOutbox(registrationContext); + }); + + options.UsingRabbitMq((busRegistrationContext, busFactoryConfigurator) => + { + ConfigureRabbitMqHost( + busFactoryConfigurator, + busRegistrationContext.GetRequiredService()); + + busFactoryConfigurator.ConfigureEndpoints(busRegistrationContext); + }); + }); + + return serviceDescriptors; + } + + 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 => + { + 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 275ddd5..bce11e7 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/ConsumeUploadIntentsLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/ConsumeUploadIntentsLog.cs @@ -1,5 +1,4 @@ -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 609258a..0ee7c18 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/CopyFileInBucketLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/CopyFileInBucketLog.cs @@ -1,6 +1,5 @@ -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 bb00b6e..84769ee 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromBucketLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromBucketLog.cs @@ -1,7 +1,6 @@ -namespace Musify.Infrastructure.MassTransit.Logs -{ - public record DownloadFileFromBucketLog( - string Bucket, - string Key, - string DestinationFilePath); -} \ No newline at end of file +namespace Musify.Infrastructure.MassTransit.Logs; + +public record DownloadFileFromBucketLog( + string Bucket, + string Key, + string DestinationFilePath); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromUrlLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromUrlLog.cs index 5c80cc1..f063578 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromUrlLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/DownloadFileFromUrlLog.cs @@ -1,6 +1,5 @@ -namespace Musify.Infrastructure.MassTransit.Logs -{ - public record DownloadFileFromUrlLog( - string SourceUrl, - string DestinationFilePath); -} +namespace Musify.Infrastructure.MassTransit.Logs; + +public record DownloadFileFromUrlLog( + string SourceUrl, + string DestinationFilePath); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/GeneratePictureWorkflowPathsLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/GeneratePictureWorkflowPathsLog.cs index 9a9c223..ed10990 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/GeneratePictureWorkflowPathsLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/GeneratePictureWorkflowPathsLog.cs @@ -1,4 +1,3 @@ -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 f255d51..32349fb 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/ResizePictureLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/ResizePictureLog.cs @@ -1,4 +1,3 @@ -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 c85933a..7bcf413 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/TranscodeAudioLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/TranscodeAudioLog.cs @@ -1,4 +1,3 @@ -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 5db2002..ba61c9a 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/TransferFilesToBucketLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/TransferFilesToBucketLog.cs @@ -1,4 +1,3 @@ -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/UpdateAlbumPictureLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs index 7563dbf..7297012 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateAlbumPictureLog.cs @@ -1,9 +1,8 @@ -namespace Musify.Infrastructure.MassTransit.Logs -{ - public record UpdateAlbumPictureLog( - Guid AlbumId, - string PreviousOriginalPictureKey, - string PreviousSmallPictureKey, - string PreviousMediumPictureKey, - string PreviousLargePictureKey); -} +namespace Musify.Infrastructure.MassTransit.Logs; + +public record UpdateAlbumPictureLog( + Guid AlbumId, + string PreviousOriginalPictureKey, + string PreviousSmallPictureKey, + string PreviousMediumPictureKey, + string PreviousLargePictureKey); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs index 8135ec8..621bd87 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdatePlayListPictureLog.cs @@ -1,9 +1,8 @@ -namespace Musify.Infrastructure.MassTransit.Logs -{ - public record UpdatePlayListPictureLog( - Guid PlayListId, - string PreviousOriginalPictureKey, - string PreviousSmallPictureKey, - string PreviousMediumPictureKey, - string PreviousLargePictureKey); -} +namespace Musify.Infrastructure.MassTransit.Logs; + +public record UpdatePlayListPictureLog( + Guid PlayListId, + string PreviousOriginalPictureKey, + string PreviousSmallPictureKey, + string PreviousMediumPictureKey, + string PreviousLargePictureKey); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs index eee0342..e883935 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackAudioLog.cs @@ -1,7 +1,6 @@ -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/UpdateTrackPictureLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackPictureLog.cs index eb8c991..316b827 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackPictureLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UpdateTrackPictureLog.cs @@ -1,9 +1,8 @@ -namespace Musify.Infrastructure.MassTransit.Logs -{ - public record UpdateTrackPictureLog( - Guid TrackId, - string? PreviousOriginalPictureKey, - string? PreviousSmallPictureKey, - string? PreviousMediumPictureKey, - string? PreviousLargePictureKey); -} +namespace Musify.Infrastructure.MassTransit.Logs; + +public record UpdateTrackPictureLog( + Guid TrackId, + string? PreviousOriginalPictureKey, + string? PreviousSmallPictureKey, + string? PreviousMediumPictureKey, + string? PreviousLargePictureKey); diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UploadFileToBucketLog.cs b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UploadFileToBucketLog.cs index eac29f1..7cf63ff 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Logs/UploadFileToBucketLog.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Logs/UploadFileToBucketLog.cs @@ -1,4 +1,3 @@ -namespace Musify.Infrastructure.MassTransit.Logs -{ - public record UploadFileToBucketLog(string DestinationBucket, string DestinationKey); -} \ No newline at end of file +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 beaea4f..94325d5 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/MassTransitEventBus.cs @@ -1,16 +1,15 @@ using MassTransit; using Musify.Application.Contracts; -namespace Musify.Infrastructure.MassTransit -{ - public class MassTransitEventBus(IPublishEndpoint publishEndpoint) - : IEventBus - { - public async Task PublishAsync(T message, CancellationToken cancellationToken) where T : class - { - ArgumentNullException.ThrowIfNull(message, nameof(message)); +namespace Musify.Infrastructure.MassTransit; - await publishEndpoint.Publish(message, cancellationToken); - } +public class MassTransitEventBus(IPublishEndpoint publishEndpoint) + : IEventBus +{ + public async Task PublishAsync(T message, CancellationToken cancellationToken) where T : class + { + ArgumentNullException.ThrowIfNull(message, nameof(message)); + + await publishEndpoint.Publish(message, cancellationToken); } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs index 544388c..6d44957 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/ActivityNames.cs @@ -1,59 +1,58 @@ -namespace Musify.Infrastructure.MassTransit.RoutingSlip +namespace Musify.Infrastructure.MassTransit.RoutingSlip; + +internal static class ActivityNames { - 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 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 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 DownloadThumbnail = "DownloadThumbnail"; + internal const string DownloadFile = "DownloadFile"; + internal const string DownloadThumbnail = "DownloadThumbnail"; - 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 RemoveAlbumOriginalPicture = "RemoveAlbumOriginalPicture"; } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs index f19686f..73ca7bf 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AlbumPictureSourceRoutingSlipBuilder.cs @@ -1,85 +1,84 @@ using MassTransit; using MassTransit.Courier.Contracts; -using Musify.Application.Shared; using Musify.Application.Events; +using Musify.Application.Shared; using Musify.Infrastructure.MassTransit.Activities.Albums; using Musify.Infrastructure.MassTransit.Activities.Files; using Musify.Infrastructure.MassTransit.Activities.UploadIntents; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Consumers; -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +public class AlbumPictureSourceRoutingSlipBuilder { - public class AlbumPictureSourceRoutingSlipBuilder + public RoutingSlipBuilder Build(CreateAlbumResourcesEvent message, Guid? correlationId) + => BuildSlip( + message.AlbumId, + message.PictureIntentId, + message.Bucket, + message.PictureSourceKey, + message.PictureDestinationKey, + message.Small, + message.Medium, + message.Large, + correlationId); + + public RoutingSlipBuilder Build(UpdateAlbumPictureSourceEvent message, Guid? correlationId) + => BuildSlip( + message.AlbumId, + message.PictureIntentId, + message.Bucket, + message.PictureSourceKey, + message.PictureDestinationKey, + message.Small, + message.Medium, + message.Large, + correlationId); + + private static RoutingSlipBuilder BuildSlip( + Guid albumId, + Guid pictureIntentId, + string bucket, + string pictureSourceKey, + string pictureDestinationKey, + ImageSize small, + ImageSize medium, + ImageSize large, + Guid? correlationId) { - public RoutingSlipBuilder Build(CreateAlbumResourcesEvent message, Guid? correlationId) - => BuildSlip( - message.AlbumId, - message.PictureIntentId, - message.Bucket, - message.PictureSourceKey, - message.PictureDestinationKey, - message.Small, - message.Medium, - message.Large, - correlationId); + var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); - public RoutingSlipBuilder Build(UpdateAlbumPictureSourceEvent message, Guid? correlationId) - => BuildSlip( - message.AlbumId, - message.PictureIntentId, - message.Bucket, - message.PictureSourceKey, - message.PictureDestinationKey, - message.Small, - message.Medium, - message.Large, - correlationId); + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); - static RoutingSlipBuilder BuildSlip( - Guid albumId, - Guid pictureIntentId, - string bucket, - string pictureSourceKey, - string pictureDestinationKey, - ImageSize small, - ImageSize medium, - ImageSize large, - Guid? correlationId) - { - var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + routingSlipBuilder.AddActivity( + ActivityNames.CopyPictureToFinal, + EndpointHelper.BuildExecuteActivityUri(CopyFileInBucketActivity.ExecuteEndpointName), + new CopyFileInBucketArguments( + bucket, + pictureSourceKey, + bucket, + pictureDestinationKey)); - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), - RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + routingSlipBuilder.AddActivity( + ActivityNames.ConsumeUploadIntents, + EndpointHelper.BuildExecuteActivityUri(ConsumeUploadIntentsActivity.ExecuteEndpointName), + new ConsumeUploadIntentsArguments([pictureIntentId])); - routingSlipBuilder.AddActivity( - ActivityNames.CopyPictureToFinal, - EndpointHelper.BuildExecuteActivityUri(CopyFileInBucketActivity.ExecuteEndpointName), - new CopyFileInBucketArguments( - bucket, - pictureSourceKey, - bucket, - pictureDestinationKey)); + routingSlipBuilder.AddActivity( + ActivityNames.PublishAlbumPictureProcessingEvent, + EndpointHelper.BuildExecuteActivityUri(PublishAlbumPictureProcessingEventActivity.ExecuteEndpointName), + new PublishAlbumPictureProcessingEventArguments( + albumId, + bucket, + pictureDestinationKey, + small, + medium, + large)); - routingSlipBuilder.AddActivity( - ActivityNames.ConsumeUploadIntents, - EndpointHelper.BuildExecuteActivityUri(ConsumeUploadIntentsActivity.ExecuteEndpointName), - new ConsumeUploadIntentsArguments([pictureIntentId])); - - routingSlipBuilder.AddActivity( - ActivityNames.PublishAlbumPictureProcessingEvent, - EndpointHelper.BuildExecuteActivityUri(PublishAlbumPictureProcessingEventActivity.ExecuteEndpointName), - new PublishAlbumPictureProcessingEventArguments( - albumId, - bucket, - pictureDestinationKey, - small, - medium, - large)); - - return routingSlipBuilder; - } + return routingSlipBuilder; } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs index 2d807b1..c8745b8 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/AudioWorkflowRoutingSlipBuilder.cs @@ -7,66 +7,65 @@ using Musify.Infrastructure.MassTransit.Activities.Files; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Consumers; -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +public class AudioWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration) { - public class AudioWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration) + public RoutingSlipBuilder Build(UpdateTrackAudioEvent message, Guid? correlationId) { - public RoutingSlipBuilder Build(UpdateTrackAudioEvent message, Guid? correlationId) - { - var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), - RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); - routingSlipBuilder.AddActivity( - ActivityNames.GenerateAudioWorkflowPaths, - EndpointHelper.BuildExecuteActivityUri(GenerateAudioWorkflowPathsActivity.ExecuteEndpointName), - new GenerateAudioWorkflowPathsArguments( - message.TrackId, - workerConfiguration.Routes.TemporaryFilesDirectory, - message.SourceKey)); + routingSlipBuilder.AddActivity( + ActivityNames.GenerateAudioWorkflowPaths, + EndpointHelper.BuildExecuteActivityUri(GenerateAudioWorkflowPathsActivity.ExecuteEndpointName), + new GenerateAudioWorkflowPathsArguments( + message.TrackId, + workerConfiguration.Routes.TemporaryFilesDirectory, + message.SourceKey)); - routingSlipBuilder.AddActivity( - ActivityNames.DownloadFile, - EndpointHelper.BuildExecuteActivityUri(DownloadFileFromBucketActivity.ExecuteEndpointName), - new DownloadFileFromBucketArguments( - message.SourceBucket, - message.SourceKey, - RoutingSlipVariableNames.Audio.SourceFilePath)); + routingSlipBuilder.AddActivity( + ActivityNames.DownloadFile, + EndpointHelper.BuildExecuteActivityUri(DownloadFileFromBucketActivity.ExecuteEndpointName), + new DownloadFileFromBucketArguments( + message.SourceBucket, + message.SourceKey, + RoutingSlipVariableNames.Audio.SourceFilePath)); - routingSlipBuilder.AddActivity( - ActivityNames.TranscodeAudio, - EndpointHelper.BuildExecuteActivityUri(TranscodeAudioActivity.ExecuteEndpointName), - new TranscodeAudioArguments( - message.TrackId, - RoutingSlipVariableNames.Audio.SourceFilePath, - RoutingSlipVariableNames.Workflow.TemporalDirectory)); + routingSlipBuilder.AddActivity( + ActivityNames.TranscodeAudio, + EndpointHelper.BuildExecuteActivityUri(TranscodeAudioActivity.ExecuteEndpointName), + new TranscodeAudioArguments( + message.TrackId, + RoutingSlipVariableNames.Audio.SourceFilePath, + RoutingSlipVariableNames.Workflow.TemporalDirectory)); - routingSlipBuilder.AddActivity( - ActivityNames.TransferFiles, - EndpointHelper.BuildExecuteActivityUri(TransferFilesToBucketActivity.ExecuteEndpointName), - new TransferFilesToBucketArguments( - message.DestinationBucket, - RoutingSlipVariableNames.Audio.TranscodedDirectory, - message.DestinationFolderKey)); + routingSlipBuilder.AddActivity( + ActivityNames.TransferFiles, + EndpointHelper.BuildExecuteActivityUri(TransferFilesToBucketActivity.ExecuteEndpointName), + new TransferFilesToBucketArguments( + message.DestinationBucket, + RoutingSlipVariableNames.Audio.TranscodedDirectory, + message.DestinationFolderKey)); - routingSlipBuilder.AddActivity( - ActivityNames.UpdateTrackAudio, - EndpointHelper.BuildExecuteActivityUri(UpdateTrackAudioActivity.ExecuteEndpointName), - new UpdateTrackAudioArguments( - message.TrackId, - message.DestinationFolderKey, - RoutingSlipVariableNames.Audio.DurationSeconds)); + routingSlipBuilder.AddActivity( + ActivityNames.UpdateTrackAudio, + EndpointHelper.BuildExecuteActivityUri(UpdateTrackAudioActivity.ExecuteEndpointName), + new UpdateTrackAudioArguments( + message.TrackId, + message.DestinationFolderKey, + RoutingSlipVariableNames.Audio.DurationSeconds)); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.TrackId); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.TrackAudio); - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName), - RoutingSlipEvents.Faulted); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.TrackId); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.TrackAudio); + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName), + RoutingSlipEvents.Faulted); - return routingSlipBuilder; - } + return routingSlipBuilder; } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs index fd9612f..e2aa438 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/CreateTrackRoutingSlipBuilder.cs @@ -7,57 +7,56 @@ using Musify.Infrastructure.MassTransit.Activities.UploadIntents; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Consumers; -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +public class CreateTrackRoutingSlipBuilder { - public class CreateTrackRoutingSlipBuilder + public RoutingSlipBuilder Build(CreateTrackResourcesEvent message, Guid? correlationId) { - public RoutingSlipBuilder Build(CreateTrackResourcesEvent message, Guid? correlationId) - { - var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), - RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); - routingSlipBuilder.AddActivity( - ActivityNames.CopyPictureToFinal, - EndpointHelper.BuildExecuteActivityUri(CopyFileInBucketActivity.ExecuteEndpointName), - new CopyFileInBucketArguments( - message.Bucket, - message.PictureSourceKey, - message.Bucket, - message.PictureDestinationKey)); + routingSlipBuilder.AddActivity( + ActivityNames.CopyPictureToFinal, + EndpointHelper.BuildExecuteActivityUri(CopyFileInBucketActivity.ExecuteEndpointName), + new CopyFileInBucketArguments( + message.Bucket, + message.PictureSourceKey, + message.Bucket, + message.PictureDestinationKey)); - routingSlipBuilder.AddActivity( - ActivityNames.CopyAudioToFinal, - EndpointHelper.BuildExecuteActivityUri(CopyFileInBucketActivity.ExecuteEndpointName), - new CopyFileInBucketArguments( - message.Bucket, - message.AudioSourceKey, - message.Bucket, - message.AudioDestinationKey)); + routingSlipBuilder.AddActivity( + ActivityNames.CopyAudioToFinal, + EndpointHelper.BuildExecuteActivityUri(CopyFileInBucketActivity.ExecuteEndpointName), + new CopyFileInBucketArguments( + message.Bucket, + message.AudioSourceKey, + message.Bucket, + message.AudioDestinationKey)); - routingSlipBuilder.AddActivity( - ActivityNames.ConsumeUploadIntents, - EndpointHelper.BuildExecuteActivityUri(ConsumeUploadIntentsActivity.ExecuteEndpointName), - new ConsumeUploadIntentsArguments( - [message.PictureIntentId, message.AudioIntentId])); + routingSlipBuilder.AddActivity( + ActivityNames.ConsumeUploadIntents, + EndpointHelper.BuildExecuteActivityUri(ConsumeUploadIntentsActivity.ExecuteEndpointName), + new ConsumeUploadIntentsArguments( + [message.PictureIntentId, message.AudioIntentId])); - routingSlipBuilder.AddActivity( - ActivityNames.PublishTrackProcessingEvents, - EndpointHelper.BuildExecuteActivityUri(PublishTrackProcessingEventsActivity.ExecuteEndpointName), - new PublishTrackProcessingEventsArguments( - message.TrackId, - message.Bucket, - message.PictureDestinationKey, - message.AudioDestinationKey, - message.AudioProcessedFolderKey, - message.Small, - message.Medium, - message.Large)); + routingSlipBuilder.AddActivity( + ActivityNames.PublishTrackProcessingEvents, + EndpointHelper.BuildExecuteActivityUri(PublishTrackProcessingEventsActivity.ExecuteEndpointName), + new PublishTrackProcessingEventsArguments( + message.TrackId, + message.Bucket, + message.PictureDestinationKey, + message.AudioDestinationKey, + message.AudioProcessedFolderKey, + message.Small, + message.Medium, + message.Large)); - return routingSlipBuilder; - } + return routingSlipBuilder; } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs index 9af5547..306a11e 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeletePlayListRoutingSlipBuilder.cs @@ -2,93 +2,92 @@ using MassTransit; using MassTransit.Courier.Contracts; using Microsoft.EntityFrameworkCore; using Musify.Application.Configuration; +using Musify.Application.Contracts; using Musify.Infrastructure.MassTransit.Activities.Files; using Musify.Infrastructure.MassTransit.Activities.PlayLists; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Consumers; -using Musify.Application.Contracts; -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +public class DeletePlayListRoutingSlipBuilder( + IDatabase database, + PlayListConfiguration playListConfiguration, + ApplicationStorageConfiguration storageConfiguration) { - public class DeletePlayListRoutingSlipBuilder( - IDatabase database, - PlayListConfiguration playListConfiguration, - ApplicationStorageConfiguration storageConfiguration) + public async Task BuildAsync( + Guid playListId, + long userId, + Guid? correlationId, + CancellationToken cancellationToken) { - public async Task BuildAsync( - Guid playListId, - long userId, - Guid? correlationId, - CancellationToken cancellationToken) + var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + + var playList = await database.PlayLists + .AsNoTracking() + .SingleOrDefaultAsync(p => p.Id == playListId, cancellationToken); + + routingSlipBuilder.AddActivity( + ActivityNames.MarkPlayListAsRemoving, + EndpointHelper.BuildExecuteActivityUri(MarkPlayListAsRemovingActivity.ExecuteEndpointName), + new MarkPlayListAsRemovingArguments(playListId)); + + if (playList?.Pictures != null) { - var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + var bucket = storageConfiguration.Bucket; + var routes = playListConfiguration.Routes; - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), - RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); - - var playList = await database.PlayLists - .AsNoTracking() - .SingleOrDefaultAsync(p => p.Id == playListId, cancellationToken); - - routingSlipBuilder.AddActivity( - ActivityNames.MarkPlayListAsRemoving, - EndpointHelper.BuildExecuteActivityUri(MarkPlayListAsRemovingActivity.ExecuteEndpointName), - new MarkPlayListAsRemovingArguments(playListId)); - - if (playList?.Pictures != null) + if (playList.Pictures.OriginalName is { } originalName) { - var bucket = storageConfiguration.Bucket; - var routes = playListConfiguration.Routes; - - if (playList.Pictures.OriginalName is { } originalName) - { - routingSlipBuilder.AddActivity( - ActivityNames.RemovePlayListOriginalPicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - routes.BuildOriginalPicturePath(userId, originalName))); - } - - if (playList.Pictures.SmallName is { } smallName) - { - routingSlipBuilder.AddActivity( - ActivityNames.RemovePlayListSmallPicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - routes.BuildSmallPicturePath(smallName))); - } - - if (playList.Pictures.MediumName is { } mediumName) - { - routingSlipBuilder.AddActivity( - ActivityNames.RemovePlayListMediumPicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - routes.BuildMediumPicturePath(mediumName))); - } - - if (playList.Pictures.LargeName is { } largeName) - { - routingSlipBuilder.AddActivity( - ActivityNames.RemovePlayListLargePicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - routes.BuildLargePicturePath(largeName))); - } + routingSlipBuilder.AddActivity( + ActivityNames.RemovePlayListOriginalPicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + routes.BuildOriginalPicturePath(userId, originalName))); } - routingSlipBuilder.AddActivity( - ActivityNames.DeletePlayListFromDb, - EndpointHelper.BuildExecuteActivityUri(DeletePlayListFromDbActivity.ExecuteEndpointName), - new DeletePlayListFromDbArguments(playListId)); + if (playList.Pictures.SmallName is { } smallName) + { + routingSlipBuilder.AddActivity( + ActivityNames.RemovePlayListSmallPicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + routes.BuildSmallPicturePath(smallName))); + } - return routingSlipBuilder; + if (playList.Pictures.MediumName is { } mediumName) + { + routingSlipBuilder.AddActivity( + ActivityNames.RemovePlayListMediumPicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + routes.BuildMediumPicturePath(mediumName))); + } + + if (playList.Pictures.LargeName is { } largeName) + { + routingSlipBuilder.AddActivity( + ActivityNames.RemovePlayListLargePicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + routes.BuildLargePicturePath(largeName))); + } } + + routingSlipBuilder.AddActivity( + ActivityNames.DeletePlayListFromDb, + EndpointHelper.BuildExecuteActivityUri(DeletePlayListFromDbActivity.ExecuteEndpointName), + new DeletePlayListFromDbArguments(playListId)); + + return routingSlipBuilder; } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs index 35b8000..a6da70c 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/DeleteTrackRoutingSlipBuilder.cs @@ -2,106 +2,105 @@ using MassTransit; using MassTransit.Courier.Contracts; using Microsoft.EntityFrameworkCore; using Musify.Application.Configuration; +using Musify.Application.Contracts; using Musify.Infrastructure.MassTransit.Activities.Files; using Musify.Infrastructure.MassTransit.Activities.Tracks; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Consumers; -using Musify.Application.Contracts; -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +public class DeleteTrackRoutingSlipBuilder( + IDatabase database, + TrackConfiguration trackConfiguration, + ApplicationStorageConfiguration storageConfiguration) { - public class DeleteTrackRoutingSlipBuilder( - IDatabase database, - TrackConfiguration trackConfiguration, - ApplicationStorageConfiguration storageConfiguration) + public async Task BuildAsync( + Guid trackId, + long userId, + Guid? correlationId, + CancellationToken cancellationToken) { - public async Task BuildAsync( - Guid trackId, - long userId, - Guid? correlationId, - CancellationToken cancellationToken) + var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + + var track = await database.Tracks + .AsNoTracking() + .SingleOrDefaultAsync(t => t.Id == trackId, cancellationToken); + + routingSlipBuilder.AddActivity( + ActivityNames.MarkTrackAsRemoving, + EndpointHelper.BuildExecuteActivityUri(MarkTrackAsRemovingActivity.ExecuteEndpointName), + new MarkTrackAsRemovingArguments(trackId)); + + if (track != null) { - var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + var bucket = storageConfiguration.Bucket; - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), - RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); - - var track = await database.Tracks - .AsNoTracking() - .SingleOrDefaultAsync(t => t.Id == trackId, cancellationToken); - - routingSlipBuilder.AddActivity( - ActivityNames.MarkTrackAsRemoving, - EndpointHelper.BuildExecuteActivityUri(MarkTrackAsRemovingActivity.ExecuteEndpointName), - new MarkTrackAsRemovingArguments(trackId)); - - if (track != null) + if (!string.IsNullOrEmpty(track.Pictures.OriginalName)) { - var bucket = storageConfiguration.Bucket; - - if (!string.IsNullOrEmpty(track.Pictures.OriginalName)) - { - routingSlipBuilder.AddActivity( - ActivityNames.RemoveTrackOriginalPicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - trackConfiguration.Routes.BuildOriginalPicturePath(userId, track.Pictures.OriginalName))); - } - - if (track.Pictures.IsProcessed) - { - routingSlipBuilder.AddActivity( - ActivityNames.RemoveTrackSmallPicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - trackConfiguration.Routes.BuildSmallPicturePath(track.Pictures.SmallName))); - - routingSlipBuilder.AddActivity( - ActivityNames.RemoveTrackMediumPicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - trackConfiguration.Routes.BuildMediumPicturePath(track.Pictures.MediumName))); - - routingSlipBuilder.AddActivity( - ActivityNames.RemoveTrackLargePicture, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - trackConfiguration.Routes.BuildLargePicturePath(track.Pictures.LargeName))); - } - - if (!string.IsNullOrEmpty(track.Audio.OriginalName)) - { - routingSlipBuilder.AddActivity( - ActivityNames.RemoveTrackOriginalAudio, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - trackConfiguration.Routes.BuildOriginalAudioPath(userId, track.Audio.OriginalName))); - } - - if (track.Audio.IsProcessed) - { - routingSlipBuilder.AddActivity( - ActivityNames.RemoveTrackProcessedAudio, - EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), - new RemoveFileFromBucketArguments( - bucket, - trackConfiguration.Routes.BuildProcessedAudioPath(track.Audio.FolderName))); - } + routingSlipBuilder.AddActivity( + ActivityNames.RemoveTrackOriginalPicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + trackConfiguration.Routes.BuildOriginalPicturePath(userId, track.Pictures.OriginalName))); } - routingSlipBuilder.AddActivity( - ActivityNames.DeleteTrackFromDb, - EndpointHelper.BuildExecuteActivityUri(DeleteTrackFromDbActivity.ExecuteEndpointName), - new DeleteTrackFromDbArguments(trackId)); + if (track.Pictures.IsProcessed) + { + routingSlipBuilder.AddActivity( + ActivityNames.RemoveTrackSmallPicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + trackConfiguration.Routes.BuildSmallPicturePath(track.Pictures.SmallName))); - return routingSlipBuilder; + routingSlipBuilder.AddActivity( + ActivityNames.RemoveTrackMediumPicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + trackConfiguration.Routes.BuildMediumPicturePath(track.Pictures.MediumName))); + + routingSlipBuilder.AddActivity( + ActivityNames.RemoveTrackLargePicture, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + trackConfiguration.Routes.BuildLargePicturePath(track.Pictures.LargeName))); + } + + if (!string.IsNullOrEmpty(track.Audio.OriginalName)) + { + routingSlipBuilder.AddActivity( + ActivityNames.RemoveTrackOriginalAudio, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + trackConfiguration.Routes.BuildOriginalAudioPath(userId, track.Audio.OriginalName))); + } + + if (track.Audio.IsProcessed) + { + routingSlipBuilder.AddActivity( + ActivityNames.RemoveTrackProcessedAudio, + EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName), + new RemoveFileFromBucketArguments( + bucket, + trackConfiguration.Routes.BuildProcessedAudioPath(track.Audio.FolderName))); + } } + + routingSlipBuilder.AddActivity( + ActivityNames.DeleteTrackFromDb, + EndpointHelper.BuildExecuteActivityUri(DeleteTrackFromDbActivity.ExecuteEndpointName), + new DeleteTrackFromDbArguments(trackId)); + + return routingSlipBuilder; } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs index 34bf4e4..b827afb 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PictureWorkflowRoutingSlipBuilder.cs @@ -1,185 +1,184 @@ using MassTransit; using MassTransit.Courier.Contracts; -using Musify.Application.Shared; using Musify.Application.Events; +using Musify.Application.Shared; using Musify.Infrastructure.Configuration; -using Musify.Infrastructure.MassTransit.Activities.Pictures; using Musify.Infrastructure.MassTransit.Activities.Files; +using Musify.Infrastructure.MassTransit.Activities.Pictures; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Consumers; -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration) { - public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration) + public RoutingSlipBuilder Build(UpdateTrackPictureEvent message, Guid? correlationId) { - public RoutingSlipBuilder Build(UpdateTrackPictureEvent message, Guid? correlationId) - { - var routingSlipBuilder = BuildPictureWorkflow( - message.Bucket, + var routingSlipBuilder = BuildPictureWorkflow( + message.Bucket, + message.SourceKey, + message.Small, + message.Medium, + message.Large, + correlationId); + + routingSlipBuilder.AddActivity( + ActivityNames.UpdateTrackPicture, + EndpointHelper.BuildExecuteActivityUri(UpdateTrackPictureActivity.ExecuteEndpointName), + new UpdateTrackPictureArguments( + message.TrackId, message.SourceKey, - message.Small, - message.Medium, - message.Large, - correlationId); - - routingSlipBuilder.AddActivity( - ActivityNames.UpdateTrackPicture, - EndpointHelper.BuildExecuteActivityUri(UpdateTrackPictureActivity.ExecuteEndpointName), - new UpdateTrackPictureArguments( - message.TrackId, - message.SourceKey, - RoutingSlipVariableNames.Picture.SmallResizedFilePath, - RoutingSlipVariableNames.Picture.MediumResizedFilePath, - RoutingSlipVariableNames.Picture.LargeResizedFilePath)); - - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.TrackId); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.TrackPicture); - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName), - RoutingSlipEvents.Faulted); - - return routingSlipBuilder; - } - - public RoutingSlipBuilder Build(UpdatePlayListPictureEvent message, Guid? correlationId) - { - var routingSlipBuilder = BuildPictureWorkflow( - message.Bucket, - message.SourceKey, - message.Small, - message.Medium, - message.Large, - correlationId); - - routingSlipBuilder.AddActivity( - ActivityNames.UpdatePlayListPicture, - EndpointHelper.BuildExecuteActivityUri(UpdatePlayListPictureActivity.ExecuteEndpointName), - new UpdatePlayListPictureArguments( - message.PlayListId, - message.SourceKey, - RoutingSlipVariableNames.Picture.SmallResizedFilePath, - RoutingSlipVariableNames.Picture.MediumResizedFilePath, - RoutingSlipVariableNames.Picture.LargeResizedFilePath)); - - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.PlayListId); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.PlayListPicture); - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName), - RoutingSlipEvents.Faulted); - - return routingSlipBuilder; - } - - public RoutingSlipBuilder Build(UpdateAlbumPictureEvent message, Guid? correlationId) - { - var routingSlipBuilder = BuildPictureWorkflow( - message.Bucket, - message.SourceKey, - message.Small, - message.Medium, - message.Large, - correlationId); - - routingSlipBuilder.AddActivity( - ActivityNames.UpdateAlbumPicture, - EndpointHelper.BuildExecuteActivityUri(UpdateAlbumPictureActivity.ExecuteEndpointName), - new UpdateAlbumPictureArguments( - message.AlbumId, - message.SourceKey, - RoutingSlipVariableNames.Picture.SmallResizedFilePath, - RoutingSlipVariableNames.Picture.MediumResizedFilePath, - RoutingSlipVariableNames.Picture.LargeResizedFilePath)); - - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.AlbumId); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.AlbumPicture); - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName), - RoutingSlipEvents.Faulted); - - return routingSlipBuilder; - } - - RoutingSlipBuilder BuildPictureWorkflow( - string bucket, - string sourceKey, - ImageSize small, - ImageSize medium, - ImageSize large, - Guid? correlationId) - { - var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); - - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), - RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); - - routingSlipBuilder.AddActivity( - ActivityNames.GeneratePictureWorkflowPaths, - EndpointHelper.BuildExecuteActivityUri(GeneratePictureWorkflowPathsActivity.ExecuteEndpointName), - new GeneratePictureWorkflowPathsArguments( - workerConfiguration.Routes.TemporaryFilesDirectory, - sourceKey)); - - routingSlipBuilder.AddActivity( - ActivityNames.DownloadFile, - EndpointHelper.BuildExecuteActivityUri(DownloadFileFromBucketActivity.ExecuteEndpointName), - new DownloadFileFromBucketArguments( - bucket, - sourceKey, - RoutingSlipVariableNames.Picture.OriginalFilePath)); - - AddResizeActivity( - routingSlipBuilder, - ActivityNames.ResizeSmall, - ActivityNames.UploadSmall, - bucket, RoutingSlipVariableNames.Picture.SmallResizedFilePath, - small); - - AddResizeActivity( - routingSlipBuilder, - ActivityNames.ResizeMedium, - ActivityNames.UploadMedium, - bucket, RoutingSlipVariableNames.Picture.MediumResizedFilePath, - medium); + RoutingSlipVariableNames.Picture.LargeResizedFilePath)); - AddResizeActivity( - routingSlipBuilder, - ActivityNames.ResizeLarge, - ActivityNames.UploadLarge, - bucket, - RoutingSlipVariableNames.Picture.LargeResizedFilePath, - large); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.TrackId); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.TrackPicture); + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName), + RoutingSlipEvents.Faulted); - return routingSlipBuilder; - } - - static void AddResizeActivity( - RoutingSlipBuilder routingSlipBuilder, - string resizeActivityName, - string uploadActivityName, - string bucketName, - string destinationFilePathVariableName, - ImageSize imageSize) - { - routingSlipBuilder.AddActivity( - resizeActivityName, - EndpointHelper.BuildExecuteActivityUri(ResizePictureActivity.ExecuteEndpointName), - new ResizePictureLocalArguments( - RoutingSlipVariableNames.Picture.OriginalFilePath, - destinationFilePathVariableName, - imageSize.Width, - imageSize.Height)); - - routingSlipBuilder.AddActivity( - uploadActivityName, - EndpointHelper.BuildExecuteActivityUri(UploadFileToBucketActivity.ExecuteEndpointName), - new UploadFileToBucketArguments( - destinationFilePathVariableName, - bucketName, - imageSize.SavePath)); - } + return routingSlipBuilder; } -} \ No newline at end of file + + public RoutingSlipBuilder Build(UpdatePlayListPictureEvent message, Guid? correlationId) + { + var routingSlipBuilder = BuildPictureWorkflow( + message.Bucket, + message.SourceKey, + message.Small, + message.Medium, + message.Large, + correlationId); + + routingSlipBuilder.AddActivity( + ActivityNames.UpdatePlayListPicture, + EndpointHelper.BuildExecuteActivityUri(UpdatePlayListPictureActivity.ExecuteEndpointName), + new UpdatePlayListPictureArguments( + message.PlayListId, + message.SourceKey, + RoutingSlipVariableNames.Picture.SmallResizedFilePath, + RoutingSlipVariableNames.Picture.MediumResizedFilePath, + RoutingSlipVariableNames.Picture.LargeResizedFilePath)); + + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.PlayListId); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.PlayListPicture); + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName), + RoutingSlipEvents.Faulted); + + return routingSlipBuilder; + } + + public RoutingSlipBuilder Build(UpdateAlbumPictureEvent message, Guid? correlationId) + { + var routingSlipBuilder = BuildPictureWorkflow( + message.Bucket, + message.SourceKey, + message.Small, + message.Medium, + message.Large, + correlationId); + + routingSlipBuilder.AddActivity( + ActivityNames.UpdateAlbumPicture, + EndpointHelper.BuildExecuteActivityUri(UpdateAlbumPictureActivity.ExecuteEndpointName), + new UpdateAlbumPictureArguments( + message.AlbumId, + message.SourceKey, + RoutingSlipVariableNames.Picture.SmallResizedFilePath, + RoutingSlipVariableNames.Picture.MediumResizedFilePath, + RoutingSlipVariableNames.Picture.LargeResizedFilePath)); + + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.SubjectId, message.AlbumId); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.ProcessKind, RoutingSlipVariableNames.ProcessKinds.AlbumPicture); + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(ProcessingSlipFaultConsumer.QueueName), + RoutingSlipEvents.Faulted); + + return routingSlipBuilder; + } + + private RoutingSlipBuilder BuildPictureWorkflow( + string bucket, + string sourceKey, + ImageSize small, + ImageSize medium, + ImageSize large, + Guid? correlationId) + { + var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + + routingSlipBuilder.AddActivity( + ActivityNames.GeneratePictureWorkflowPaths, + EndpointHelper.BuildExecuteActivityUri(GeneratePictureWorkflowPathsActivity.ExecuteEndpointName), + new GeneratePictureWorkflowPathsArguments( + workerConfiguration.Routes.TemporaryFilesDirectory, + sourceKey)); + + routingSlipBuilder.AddActivity( + ActivityNames.DownloadFile, + EndpointHelper.BuildExecuteActivityUri(DownloadFileFromBucketActivity.ExecuteEndpointName), + new DownloadFileFromBucketArguments( + bucket, + sourceKey, + RoutingSlipVariableNames.Picture.OriginalFilePath)); + + AddResizeActivity( + routingSlipBuilder, + ActivityNames.ResizeSmall, + ActivityNames.UploadSmall, + bucket, + RoutingSlipVariableNames.Picture.SmallResizedFilePath, + small); + + AddResizeActivity( + routingSlipBuilder, + ActivityNames.ResizeMedium, + ActivityNames.UploadMedium, + bucket, + RoutingSlipVariableNames.Picture.MediumResizedFilePath, + medium); + + AddResizeActivity( + routingSlipBuilder, + ActivityNames.ResizeLarge, + ActivityNames.UploadLarge, + bucket, + RoutingSlipVariableNames.Picture.LargeResizedFilePath, + large); + + return routingSlipBuilder; + } + + private static void AddResizeActivity( + RoutingSlipBuilder routingSlipBuilder, + string resizeActivityName, + string uploadActivityName, + string bucketName, + string destinationFilePathVariableName, + ImageSize imageSize) + { + routingSlipBuilder.AddActivity( + resizeActivityName, + EndpointHelper.BuildExecuteActivityUri(ResizePictureActivity.ExecuteEndpointName), + new ResizePictureLocalArguments( + RoutingSlipVariableNames.Picture.OriginalFilePath, + destinationFilePathVariableName, + imageSize.Width, + imageSize.Height)); + + routingSlipBuilder.AddActivity( + uploadActivityName, + EndpointHelper.BuildExecuteActivityUri(UploadFileToBucketActivity.ExecuteEndpointName), + new UploadFileToBucketArguments( + destinationFilePathVariableName, + bucketName, + imageSize.SavePath)); + } +} diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs index f863910..27bdaa5 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/Builders/PlayListPictureSourceRoutingSlipBuilder.cs @@ -1,85 +1,84 @@ using MassTransit; using MassTransit.Courier.Contracts; -using Musify.Application.Shared; using Musify.Application.Events; +using Musify.Application.Shared; using Musify.Infrastructure.MassTransit.Activities.Files; using Musify.Infrastructure.MassTransit.Activities.PlayLists; using Musify.Infrastructure.MassTransit.Activities.UploadIntents; using Musify.Infrastructure.MassTransit.Arguments; using Musify.Infrastructure.MassTransit.Consumers; -namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders +namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders; + +public class PlayListPictureSourceRoutingSlipBuilder { - public class PlayListPictureSourceRoutingSlipBuilder + public RoutingSlipBuilder Build(CreatePlayListResourcesEvent message, Guid? correlationId) + => BuildSlip( + message.PlayListId, + message.PictureIntentId, + message.Bucket, + message.PictureSourceKey, + message.PictureDestinationKey, + message.Small, + message.Medium, + message.Large, + correlationId); + + public RoutingSlipBuilder Build(UpdatePlayListPictureSourceEvent message, Guid? correlationId) + => BuildSlip( + message.PlayListId, + message.PictureIntentId, + message.Bucket, + message.PictureSourceKey, + message.PictureDestinationKey, + message.Small, + message.Medium, + message.Large, + correlationId); + + private static RoutingSlipBuilder BuildSlip( + Guid playListId, + Guid pictureIntentId, + string bucket, + string pictureSourceKey, + string pictureDestinationKey, + ImageSize small, + ImageSize medium, + ImageSize large, + Guid? correlationId) { - public RoutingSlipBuilder Build(CreatePlayListResourcesEvent message, Guid? correlationId) - => BuildSlip( - message.PlayListId, - message.PictureIntentId, - message.Bucket, - message.PictureSourceKey, - message.PictureDestinationKey, - message.Small, - message.Medium, - message.Large, - correlationId); + var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); + routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); - public RoutingSlipBuilder Build(UpdatePlayListPictureSourceEvent message, Guid? correlationId) - => BuildSlip( - message.PlayListId, - message.PictureIntentId, - message.Bucket, - message.PictureSourceKey, - message.PictureDestinationKey, - message.Small, - message.Medium, - message.Large, - correlationId); + routingSlipBuilder.AddSubscription( + EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), + RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); - static RoutingSlipBuilder BuildSlip( - Guid playListId, - Guid pictureIntentId, - string bucket, - string pictureSourceKey, - string pictureDestinationKey, - ImageSize small, - ImageSize medium, - ImageSize large, - Guid? correlationId) - { - var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid()); - routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty); + routingSlipBuilder.AddActivity( + ActivityNames.CopyPictureToFinal, + EndpointHelper.BuildExecuteActivityUri(CopyFileInBucketActivity.ExecuteEndpointName), + new CopyFileInBucketArguments( + bucket, + pictureSourceKey, + bucket, + pictureDestinationKey)); - routingSlipBuilder.AddSubscription( - EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName), - RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted); + routingSlipBuilder.AddActivity( + ActivityNames.ConsumeUploadIntents, + EndpointHelper.BuildExecuteActivityUri(ConsumeUploadIntentsActivity.ExecuteEndpointName), + new ConsumeUploadIntentsArguments([pictureIntentId])); - routingSlipBuilder.AddActivity( - ActivityNames.CopyPictureToFinal, - EndpointHelper.BuildExecuteActivityUri(CopyFileInBucketActivity.ExecuteEndpointName), - new CopyFileInBucketArguments( - bucket, - pictureSourceKey, - bucket, - pictureDestinationKey)); + routingSlipBuilder.AddActivity( + ActivityNames.PublishPlayListPictureProcessingEvent, + EndpointHelper.BuildExecuteActivityUri(PublishPlayListPictureProcessingEventActivity.ExecuteEndpointName), + new PublishPlayListPictureProcessingEventArguments( + playListId, + bucket, + pictureDestinationKey, + small, + medium, + large)); - routingSlipBuilder.AddActivity( - ActivityNames.ConsumeUploadIntents, - EndpointHelper.BuildExecuteActivityUri(ConsumeUploadIntentsActivity.ExecuteEndpointName), - new ConsumeUploadIntentsArguments([pictureIntentId])); - - routingSlipBuilder.AddActivity( - ActivityNames.PublishPlayListPictureProcessingEvent, - EndpointHelper.BuildExecuteActivityUri(PublishPlayListPictureProcessingEventActivity.ExecuteEndpointName), - new PublishPlayListPictureProcessingEventArguments( - playListId, - bucket, - pictureDestinationKey, - small, - medium, - large)); - - return routingSlipBuilder; - } + return routingSlipBuilder; } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs index ed2b181..71fd347 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/RoutingSlip/RoutingSlipVariableNames.cs @@ -1,36 +1,35 @@ -namespace Musify.Infrastructure.MassTransit +namespace Musify.Infrastructure.MassTransit; + +internal static class RoutingSlipVariableNames { - internal static class RoutingSlipVariableNames + internal static class Workflow { - 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 TemporalDirectory = "Workflow.TemporalDirectory"; + internal const string CorrelationId = "Workflow.CorrelationId"; + internal const string SubjectId = "Workflow.SubjectId"; + internal const string ProcessKind = "Workflow.ProcessKind"; + } - 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 static class ProcessKinds + { + internal const string TrackPicture = "TrackPicture"; + internal const string TrackAudio = "TrackAudio"; + internal const string PlayListPicture = "PlayListPicture"; + internal const string AlbumPicture = "AlbumPicture"; + } - 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/Sagas/AlbumProcessingState.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingState.cs index 8229784..0c2f23e 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingState.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingState.cs @@ -1,19 +1,18 @@ using MassTransit; -namespace Musify.Infrastructure.MassTransit.Sagas +namespace Musify.Infrastructure.MassTransit.Sagas; + +public class AlbumProcessingState : SagaStateMachineInstance { - public class AlbumProcessingState : SagaStateMachineInstance - { - public Guid CorrelationId { get; set; } + 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 332c90b..4275aa4 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/AlbumProcessingStateMachine.cs @@ -1,51 +1,50 @@ using MassTransit; using Musify.Application.Events; -namespace Musify.Infrastructure.MassTransit.Sagas +namespace Musify.Infrastructure.MassTransit.Sagas; + +public sealed class AlbumProcessingStateMachine : MassTransitStateMachine { - public sealed class AlbumProcessingStateMachine : MassTransitStateMachine + 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 State Processing { get; private set; } = null!; - public State Failed { get; private set; } = null!; + InstanceState(state => state.CurrentState); - public Event ProcessingStarted { get; private set; } = null!; - public Event PictureProcessed { get; private set; } = null!; - public Event PictureFailed { get; private set; } = null!; + Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.AlbumId)); + Event(() => PictureProcessed, config => config.CorrelateById(context => context.Message.AlbumId)); + Event(() => PictureFailed, config => config.CorrelateById(context => context.Message.AlbumId)); - public AlbumProcessingStateMachine() - { - 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)); - Event(() => PictureFailed, config => config.CorrelateById(context => context.Message.AlbumId)); + 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))); - - During(Failed, - Ignore(PictureProcessed), - Ignore(PictureFailed)); - - SetCompletedWhenFinalized(); - } + SetCompletedWhenFinalized(); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingState.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingState.cs index 213112b..4d3c261 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingState.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingState.cs @@ -1,19 +1,18 @@ using MassTransit; -namespace Musify.Infrastructure.MassTransit.Sagas +namespace Musify.Infrastructure.MassTransit.Sagas; + +public class PlayListProcessingState : SagaStateMachineInstance { - public class PlayListProcessingState : SagaStateMachineInstance - { - public Guid CorrelationId { get; set; } + 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 fa89e46..c9eaec4 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingStateMachine.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/PlayListProcessingStateMachine.cs @@ -1,51 +1,50 @@ using MassTransit; using Musify.Application.Events; -namespace Musify.Infrastructure.MassTransit.Sagas +namespace Musify.Infrastructure.MassTransit.Sagas; + +public sealed class PlayListProcessingStateMachine : MassTransitStateMachine { - public sealed class PlayListProcessingStateMachine : MassTransitStateMachine + 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 State Processing { get; private set; } = null!; - public State Failed { get; private set; } = null!; + InstanceState(state => state.CurrentState); - public Event ProcessingStarted { get; private set; } = null!; - public Event PictureProcessed { get; private set; } = null!; - public Event PictureFailed { get; private set; } = null!; + Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.PlayListId)); + Event(() => PictureProcessed, config => config.CorrelateById(context => context.Message.PlayListId)); + Event(() => PictureFailed, config => config.CorrelateById(context => context.Message.PlayListId)); - public PlayListProcessingStateMachine() - { - 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)); - Event(() => PictureFailed, config => config.CorrelateById(context => context.Message.PlayListId)); + 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))); - - During(Failed, - Ignore(PictureProcessed), - Ignore(PictureFailed)); - - SetCompletedWhenFinalized(); - } + SetCompletedWhenFinalized(); } } diff --git a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingState.cs b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingState.cs index 707a3d6..2d0397b 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingState.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingState.cs @@ -1,25 +1,24 @@ using MassTransit; -namespace Musify.Infrastructure.MassTransit.Sagas +namespace Musify.Infrastructure.MassTransit.Sagas; + +public class TrackProcessingState : SagaStateMachineInstance { - public class TrackProcessingState : SagaStateMachineInstance - { - public Guid CorrelationId { get; set; } + 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 9b157fb..c01565e 100644 --- a/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs +++ b/backend/Core/Musify.Infrastructure/MassTransit/Sagas/TrackProcessingStateMachine.cs @@ -1,80 +1,79 @@ using MassTransit; using Musify.Application.Events; -namespace Musify.Infrastructure.MassTransit.Sagas +namespace Musify.Infrastructure.MassTransit.Sagas; + +public sealed class TrackProcessingStateMachine : MassTransitStateMachine { - public sealed class TrackProcessingStateMachine : MassTransitStateMachine + 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 State Processing { get; private set; } = null!; - public State Failed { get; private set; } = null!; + InstanceState(state => state.CurrentState); - 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!; + Event(() => ProcessingStarted, config => config.CorrelateById(context => context.Message.TrackId)); + Event(() => PictureProcessed, config => config.CorrelateById(context => context.Message.TrackId)); + Event(() => AudioProcessed, config => config.CorrelateById(context => context.Message.TrackId)); + Event(() => PictureFailed, config => config.CorrelateById(context => context.Message.TrackId)); + Event(() => AudioFailed, config => config.CorrelateById(context => context.Message.TrackId)); - public TrackProcessingStateMachine() - { - 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)); - Event(() => AudioProcessed, config => config.CorrelateById(context => context.Message.TrackId)); - Event(() => PictureFailed, config => config.CorrelateById(context => context.Message.TrackId)); - Event(() => AudioFailed, config => config.CorrelateById(context => context.Message.TrackId)); + 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))); - - During(Failed, - Ignore(PictureProcessed), - Ignore(AudioProcessed), - Ignore(PictureFailed), - Ignore(AudioFailed)); - - SetCompletedWhenFinalized(); - } + SetCompletedWhenFinalized(); } } diff --git a/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs index 75c4903..ca6adcc 100644 --- a/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Observability/OpenTelemetryDependencyInjection.cs @@ -8,53 +8,52 @@ using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; -namespace Musify.Infrastructure.Observability +namespace Musify.Infrastructure.Observability; + +public static class OpenTelemetryDependencyInjection { - public static class OpenTelemetryDependencyInjection + public static IServiceCollection AddObservability(this IServiceCollection serviceDescriptors, IConfiguration configuration) { - public static IServiceCollection AddObservability(this IServiceCollection serviceDescriptors, IConfiguration configuration) - { - serviceDescriptors - .AddOptionsWithValidateOnStart() - .Bind(configuration.GetRequiredSection(OpenTelemetryConfiguration.SectionName)) - .ValidateDataAnnotations(); + serviceDescriptors + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(OpenTelemetryConfiguration.SectionName)) + .ValidateDataAnnotations(); - serviceDescriptors.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService>().Value); + serviceDescriptors.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); - serviceDescriptors - .AddOptions() - .Configure((exporterOptions, openTelemetryConfiguration) => - { - exporterOptions.Endpoint = new Uri(openTelemetryConfiguration.OtlpEndpoint); - } ); + serviceDescriptors + .AddOptions() + .Configure((exporterOptions, openTelemetryConfiguration) => + { + exporterOptions.Endpoint = new Uri(openTelemetryConfiguration.OtlpEndpoint); + }); - serviceDescriptors - .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() - ); + serviceDescriptors + .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 serviceDescriptors; - } + return serviceDescriptors; } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/AuditableEntityInterceptor.cs b/backend/Core/Musify.Infrastructure/Persistence/AuditableEntityInterceptor.cs index 6a490c5..38681b8 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/AuditableEntityInterceptor.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/AuditableEntityInterceptor.cs @@ -2,43 +2,42 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Musify.Domain.Abstractions; -namespace Musify.Infrastructure.Persistence +namespace Musify.Infrastructure.Persistence; + +public sealed class AuditableEntityInterceptor : SaveChangesInterceptor { - public sealed class AuditableEntityInterceptor : SaveChangesInterceptor + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) { - public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + 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()) { - 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()) + if (entry.State == EntityState.Added) { - 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; - } + 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 9085718..1e84194 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Database.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Database.cs @@ -1,216 +1,215 @@ +using MassTransit; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; -using MassTransit; +using Musify.Application.Contracts; using Musify.Domain.Entities; using Musify.Domain.ValueObjects; using Musify.Infrastructure.Configuration; using Musify.Infrastructure.MassTransit.Sagas; using AppIDatabase = Musify.Application.Contracts.IDatabase; -using Musify.Application.Contracts; -namespace Musify.Infrastructure.Persistence +namespace Musify.Infrastructure.Persistence; + +public class Database(DatabaseConfiguration configuration) + : DbContext, AppIDatabase { - public class Database(DatabaseConfiguration configuration) - : DbContext, AppIDatabase + private static readonly AuditableEntityInterceptor AuditInterceptor = new(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { - private static readonly AuditableEntityInterceptor AuditInterceptor = new(); + optionsBuilder + .UseNpgsql(configuration.ConnectionString, o => o.MapEnum("genre")) + .AddInterceptors(AuditInterceptor); + } - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder - .UseNpgsql(configuration.ConnectionString, o => o.MapEnum("genre")) - .AddInterceptors(AuditInterceptor); - } + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); + modelBuilder.AddInboxStateEntity(); + modelBuilder.AddOutboxMessageEntity(); + modelBuilder.AddOutboxStateEntity(); - modelBuilder.AddInboxStateEntity(); - modelBuilder.AddOutboxMessageEntity(); - modelBuilder.AddOutboxStateEntity(); + var trackProcessing = modelBuilder.Entity(); + trackProcessing.HasKey(state => state.CorrelationId); + trackProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); + trackProcessing.Property(state => state.CurrentState).HasMaxLength(64); - 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"); + audio.Property(a => a.DownloadRequested).HasColumnName("DownloadRequested"); + audio.Property(a => a.RetryCount).HasColumnName("RetryCount"); + audio.Property(a => a.LastRetryAt).HasColumnName("LastRetryAt"); + }); + 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"); - audio.Property(a => a.DownloadRequested).HasColumnName("DownloadRequested"); - audio.Property(a => a.RetryCount).HasColumnName("RetryCount"); - audio.Property(a => a.LastRetryAt).HasColumnName("LastRetryAt"); - }); - 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); + pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64); + pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64); + }); + 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); - pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64); - pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64); - }); - 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(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); - } + public DbSet Users => Set(); - public DbSet Users => Set(); + public DbSet Tracks => Set(); - public DbSet Tracks => Set(); + public DbSet PlayLists => Set(); - public DbSet PlayLists => Set(); + public DbSet Albums => Set(); - public DbSet Albums => Set(); + public DbSet AlbumHasTracks => Set(); - public DbSet AlbumHasTracks => Set(); + public DbSet UserHasTracks => Set(); - public DbSet UserHasTracks => Set(); + public DbSet PlayListHasTracks => Set(); - public DbSet PlayListHasTracks => Set(); + public DbSet Mixes => Set(); - public DbSet Mixes => Set(); + public DbSet MixItems => Set(); - public DbSet MixItems => Set(); + public DbSet Uploads => Set(); - public DbSet Uploads => Set(); + public DbSet UploadIntents => Set(); - public DbSet UploadIntents => Set(); + public DbSet ListeningHistories => Set(); - public DbSet ListeningHistories => Set(); + public DbSet TrackLikes => Set(); - public DbSet TrackLikes => Set(); + public DbSet TrackTags => Set(); - public DbSet TrackTags => Set(); + public DbSet UserFollows => 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 BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) - { - var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); - return new DatabaseTransaction(transaction); - } + private sealed class DatabaseTransaction(IDbContextTransaction inner) : IDatabaseTransaction + { + public Task CommitAsync(CancellationToken cancellationToken) => + inner.CommitAsync(cancellationToken); - private sealed class DatabaseTransaction(IDbContextTransaction inner) : IDatabaseTransaction - { - public Task CommitAsync(CancellationToken cancellationToken) => - inner.CommitAsync(cancellationToken); - - public ValueTask DisposeAsync() => inner.DisposeAsync(); - } + public ValueTask DisposeAsync() => inner.DisposeAsync(); } } diff --git a/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs index e577bc5..6e213f2 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDependencyInjection.cs @@ -4,25 +4,24 @@ using Microsoft.Extensions.Options; using Musify.Application.Contracts; using Musify.Infrastructure.Configuration; -namespace Musify.Infrastructure.Persistence +namespace Musify.Infrastructure.Persistence; + +public static class DatabaseDependencyInjection { - public static class DatabaseDependencyInjection + public static IServiceCollection AddDatabase(this IServiceCollection serviceDescriptors, IConfiguration configuration) { - public static IServiceCollection AddDatabase(this IServiceCollection serviceDescriptors, IConfiguration configuration) - { - serviceDescriptors - .AddOptionsWithValidateOnStart() - .Bind(configuration.GetRequiredSection(DatabaseConfiguration.SectionName)) - .ValidateDataAnnotations(); + serviceDescriptors + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(DatabaseConfiguration.SectionName)) + .ValidateDataAnnotations(); - serviceDescriptors.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService>().Value); + serviceDescriptors.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); - serviceDescriptors.AddDbContext(); + serviceDescriptors.AddDbContext(); - serviceDescriptors.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); + serviceDescriptors.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); - return serviceDescriptors; - } + return serviceDescriptors; } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs index 2b3cb7d..a71b19c 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/DatabaseDesignTimeFactory.cs @@ -1,30 +1,29 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.Extensions.Configuration; using Musify.Infrastructure.Configuration; -namespace Musify.Infrastructure.Persistence +namespace Musify.Infrastructure.Persistence; + +public class DatabaseDesignTimeFactory : IDesignTimeDbContextFactory { - public class DatabaseDesignTimeFactory : IDesignTimeDbContextFactory + public Database CreateDbContext(string[] args) { - public Database CreateDbContext(string[] args) - { - var configuration = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("DesignSettings.json", optional: true) - .AddUserSecrets() - .AddEnvironmentVariables() - .Build(); + 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."); - var optionsBuilder = new DbContextOptionsBuilder(); - optionsBuilder.UseNpgsql(databaseConfiguration.ConnectionString); + var optionsBuilder = new DbContextOptionsBuilder(); + optionsBuilder.UseNpgsql(databaseConfiguration.ConnectionString); - return new Database(databaseConfiguration); - } + return new Database(databaseConfiguration); } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs b/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs index dfe75e0..b073cea 100644 --- a/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs +++ b/backend/Core/Musify.Infrastructure/Services/AudioTranscoderService.cs @@ -1,248 +1,247 @@ -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Infrastructure.Configuration; using System.Diagnostics; using System.Globalization; using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Infrastructure.Configuration; -namespace Musify.Infrastructure.Services +namespace Musify.Infrastructure.Services; + +public partial class AudioService(ILogger logger, + AudioConfiguration audioConfiguration) + : IAudioTranscoderService { - public partial class AudioService(ILogger logger, - AudioConfiguration audioConfiguration) - : IAudioTranscoderService + private 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) { - 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) + try { - try - { - Directory.CreateDirectory(destinationPath); + Directory.CreateDirectory(destinationPath); - logger.LogInformation("Starting audio transcoding for {DestinationPath}", destinationPath); + logger.LogInformation("Starting audio transcoding for {DestinationPath}", destinationPath); - var executionResult = await ExecuteFfmpegWithInputAsync( - BuildAudioFileArguments(destinationPath, audioConfiguration.Ffmpeg), - destinationPath, - audioStream, - "TranscodeToAudioFile", - cancellationToken); - - if (executionResult.ExitCode != 0) - { - 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); - } - - logger.LogInformation("Audio transcoding completed for {DestinationPath}", destinationPath); - - var outputPath = Path.Combine(destinationPath, audioConfiguration.Ffmpeg.OutputFileName); - var duration = await GetAudioDurationAsync(outputPath, cancellationToken); - return new AudioTranscodeResult(executionResult.ExitCode, duration); - } - catch (TimeoutException timeoutException) - { - logger.LogError(timeoutException, "Audio transcoding timed out after {Timeout} for {DestinationPath}", audioConfiguration.TranscodingTimeout, destinationPath); - throw; - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to transcode audio for {DestinationPath}", destinationPath); - throw; - } - } - - async Task GetAudioDurationAsync(string filePath, CancellationToken cancellationToken) - { - var executionResult = await ExecuteFfmpegAsync( - BuildProbeDurationArguments(filePath), - Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory, - "ProbeAudioDuration", + var executionResult = await ExecuteFfmpegWithInputAsync( + BuildAudioFileArguments(destinationPath, audioConfiguration.Ffmpeg), + destinationPath, + audioStream, + "TranscodeToAudioFile", cancellationToken); - var match = DurationRegex().Match(executionResult.StandardError); - if (!match.Success) - throw new InvalidOperationException($"Could not determine audio duration for {filePath}."); + if (executionResult.ExitCode != 0) + { + 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); + } - 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); + logger.LogInformation("Audio transcoding completed for {DestinationPath}", destinationPath); - return new TimeSpan(hours, minutes, 0) + TimeSpan.FromSeconds(seconds); + var outputPath = Path.Combine(destinationPath, audioConfiguration.Ffmpeg.OutputFileName); + var duration = await GetAudioDurationAsync(outputPath, cancellationToken); + return new AudioTranscodeResult(executionResult.ExitCode, duration); } - - public async Task IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken) + catch (TimeoutException timeoutException) { - if (!File.Exists(filePath)) - { - logger.LogWarning("Audio validation requested for non-existing file {FilePath}", filePath); - return false; - } + logger.LogError(timeoutException, "Audio transcoding timed out after {Timeout} for {DestinationPath}", audioConfiguration.TranscodingTimeout, destinationPath); + throw; + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to transcode audio for {DestinationPath}", destinationPath); + throw; + } + } - try - { - logger.LogDebug("Validating audio file {FilePath}", filePath); + private async Task GetAudioDurationAsync(string filePath, CancellationToken cancellationToken) + { + var executionResult = await ExecuteFfmpegAsync( + BuildProbeDurationArguments(filePath), + Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory, + "ProbeAudioDuration", + cancellationToken); - var executionResult = await ExecuteFfmpegAsync( - BuildValidateAudioArguments(filePath), - Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory, - "ValidateAudioFile", - cancellationToken); + var match = DurationRegex().Match(executionResult.StandardError); + if (!match.Success) + throw new InvalidOperationException($"Could not determine audio duration for {filePath}."); - var isValid = executionResult.ExitCode == 0; - if (!isValid) - { - logger.LogWarning("Audio validation failed for {FilePath}: {StandardError}", filePath, executionResult.StandardError); - } - else - { - logger.LogDebug("Audio validation successful for {FilePath}", 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 isValid; - } - catch (TimeoutException timeoutException) - { - logger.LogError(timeoutException, "Audio validation timed out for {FilePath}", filePath); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to validate audio file {FilePath}", filePath); - } + return new TimeSpan(hours, minutes, 0) + TimeSpan.FromSeconds(seconds); + } + + public async Task IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken) + { + if (!File.Exists(filePath)) + { + logger.LogWarning("Audio validation requested for non-existing file {FilePath}", filePath); return false; } - async Task ExecuteFfmpegWithInputAsync( - string arguments, - string workingDirectory, - Stream inputStream, - string operationName, - CancellationToken cancellationToken) + try { - using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: true); - process.Start(); + logger.LogDebug("Validating audio file {FilePath}", filePath); - logger.LogDebug("[{Operation}] ffmpeg started (PID: {ProcessId} ) in {WorkingDirectory}", operationName, process.Id, workingDirectory); + var executionResult = await ExecuteFfmpegAsync( + BuildValidateAudioArguments(filePath), + Path.GetDirectoryName(filePath) ?? Environment.CurrentDirectory, + "ValidateAudioFile", + cancellationToken); - var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); - var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); - - if (inputStream.CanSeek) - inputStream.Position = 0; - - await inputStream.CopyToAsync(process.StandardInput.BaseStream, cancellationToken); - process.StandardInput.Close(); - - return await WaitForProcessResultAsync(process, outputTask, errorTask, operationName, cancellationToken); - } - - async Task ExecuteFfmpegAsync( - string arguments, - string workingDirectory, - string operationName, - CancellationToken cancellationToken) - { - using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: false); - 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); - - return await WaitForProcessResultAsync(process, outputTask, errorTask, operationName, cancellationToken); - } - - Process BuildProcess(string arguments, string workingDirectory, bool redirectStandardInput) - => new() + var isValid = executionResult.ExitCode == 0; + if (!isValid) { - StartInfo = new ProcessStartInfo - { - FileName = audioConfiguration.Ffmpeg.ExecutableName, - WorkingDirectory = workingDirectory, - Arguments = arguments, - RedirectStandardInput = redirectStandardInput, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - } - }; - - 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); + logger.LogWarning("Audio validation failed for {FilePath}: {StandardError}", filePath, executionResult.StandardError); } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + else { - logger.LogError("[{Operation}] ffmpeg timed out after {Timeout} (PID: {ProcessId} )", - operationName, audioConfiguration.TranscodingTimeout, process.Id); - - TryKillProcess(process); - throw new TimeoutException($"ffmpeg execution exceeded timeout {audioConfiguration.TranscodingTimeout}."); + logger.LogDebug("Audio validation successful for {FilePath}", filePath); } - 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); + return isValid; } - - void TryKillProcess(Process process) + catch (TimeoutException timeoutException) { - try - { - if (!process.HasExited) - process.Kill(); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to kill ffmpeg process (PID: {ProcessId} )", process.Id); - } + logger.LogError(timeoutException, "Audio validation timed out for {FilePath}", filePath); } - - static string BuildAudioFileArguments(string outputDirectory, FfmpegConfiguration ffmpegConfiguration) + catch (Exception exception) { - 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}\""; + logger.LogError(exception, "Failed to validate audio file {FilePath}", filePath); } - - static string BuildValidateAudioArguments(string filePath) - => $"-v error -i \"{filePath}\" -map 0:a:0 -f null -"; - - static string BuildProbeDurationArguments(string filePath) - => $"-hide_banner -i \"{filePath}\" -f null -"; + return false; } -} \ No newline at end of file + + private async Task ExecuteFfmpegWithInputAsync( + string arguments, + string workingDirectory, + Stream inputStream, + string operationName, + CancellationToken cancellationToken) + { + using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: true); + 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 (inputStream.CanSeek) + inputStream.Position = 0; + + await inputStream.CopyToAsync(process.StandardInput.BaseStream, cancellationToken); + process.StandardInput.Close(); + + return await WaitForProcessResultAsync(process, outputTask, errorTask, operationName, cancellationToken); + } + + private async Task ExecuteFfmpegAsync( + string arguments, + string workingDirectory, + string operationName, + CancellationToken cancellationToken) + { + using var process = BuildProcess(arguments, workingDirectory, redirectStandardInput: false); + 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); + + 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) + { + logger.LogError("[{Operation}] ffmpeg timed out after {Timeout} (PID: {ProcessId})", + operationName, audioConfiguration.TranscodingTimeout, process.Id); + + TryKillProcess(process); + throw new TimeoutException($"ffmpeg execution exceeded timeout {audioConfiguration.TranscodingTimeout}."); + } + + 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 (Exception exception) + { + logger.LogError(exception, "Failed to kill ffmpeg process (PID: {ProcessId})", 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 BuildValidateAudioArguments(string filePath) + => $"-v error -i \"{filePath}\" -map 0:a:0 -f null -"; + + 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 4237519..7b7e182 100644 --- a/backend/Core/Musify.Infrastructure/Services/PictureService.cs +++ b/backend/Core/Musify.Infrastructure/Services/PictureService.cs @@ -3,38 +3,37 @@ using Musify.Application.Contracts; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Processing; -namespace Musify.Infrastructure.Services +namespace Musify.Infrastructure.Services; + +public class PictureService(ILogger logger) + : IPictureService { - public class PictureService(ILogger logger) - : IPictureService + public async Task ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken) { - public async Task ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken) + try { - try + 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 { - logger.LogDebug("Resizing picture to {Width}x{Height}", width, height); - if (pictureStream.CanSeek && pictureStream.Position > 0) - pictureStream.Position = 0; + Size = new Size(width, height), + Mode = ResizeMode.Max + })); - using var picture = await Image.LoadAsync(pictureStream, cancellationToken); - picture.Mutate(options => options.Resize(new ResizeOptions - { - Size = new Size(width, height), - Mode = ResizeMode.Max - } )); + var memoryStream = new MemoryStream(); + await picture.SaveAsWebpAsync(memoryStream, cancellationToken); + memoryStream.Position = 0; - 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; - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to resize picture to {Width}x{Height}", width, height); - throw; - } + logger.LogDebug("Picture resized to {Width}x{Height}, length: {Length}", width, height, memoryStream.Length); + return memoryStream; + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to resize picture to {Width}x{Height}", width, height); + throw; } } -} \ No newline at end of file +} diff --git a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs index 734692e..8149ec8 100644 --- a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs @@ -3,108 +3,106 @@ using Hangfire; using Hangfire.PostgreSql; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Musify.Application.Configuration; +using Musify.Application.Contracts; using Musify.Infrastructure.Configuration; using Musify.Infrastructure.Jobs; -using Musify.Application.Contracts; -namespace Musify.Infrastructure.Services +namespace Musify.Infrastructure.Services; + +public static class ServicesDependencyInjection { - public static class ServicesDependencyInjection + public static IServiceCollection AddStorageService(this IServiceCollection serviceDescriptors, IConfiguration configuration) { - public static IServiceCollection AddStorageService(this IServiceCollection serviceDescriptors, IConfiguration configuration) + serviceDescriptors + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(InfrastructureStorageConfiguration.SectionName)) + .ValidateDataAnnotations(); + + serviceDescriptors.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + serviceDescriptors.AddSingleton(serviceProvider => { - serviceDescriptors - .AddOptionsWithValidateOnStart() - .Bind(configuration.GetRequiredSection(InfrastructureStorageConfiguration.SectionName)) - .ValidateDataAnnotations(); - - serviceDescriptors.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService>().Value); - - serviceDescriptors.AddSingleton(serviceProvider => + var configuration = serviceProvider.GetRequiredService(); + var s3Configuration = new AmazonS3Config { - 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); - } ); + ServiceURL = configuration.Address, + ForcePathStyle = configuration.ForcePathStyle, + UseHttp = configuration.UseHttp, + }; + return new AmazonS3Client(configuration.AccessKey, configuration.SecretAccessKey, s3Configuration); + }); - serviceDescriptors.AddScoped(); - return serviceDescriptors; - } - - public static IServiceCollection AddPictureService(this IServiceCollection serviceDescriptors, IConfiguration configuration) - { - serviceDescriptors.AddScoped(); - return serviceDescriptors; - } - - public static IServiceCollection AddUploadIntentConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration) - { - serviceDescriptors.AddValidatedOptions(configuration, UploadIntentConfiguration.SectionName); - - return serviceDescriptors; - } - - public static IServiceCollection AddUploadIntentJobs(this IServiceCollection serviceDescriptors, IConfiguration configuration) - { - serviceDescriptors.AddUploadIntentConfiguration(configuration); - - serviceDescriptors.AddHostedService(); - serviceDescriptors.AddHostedService(); - - return serviceDescriptors; - } - - public static IServiceCollection AddDailyMixGenerationJob(this IServiceCollection serviceDescriptors, IConfiguration configuration) - { - serviceDescriptors.AddHangfire((serviceProvider, hangfireConfiguration) => - { - var databaseConfiguration = serviceProvider.GetRequiredService(); - hangfireConfiguration.UsePostgreSqlStorage(options => - options.UseNpgsqlConnection(databaseConfiguration.ConnectionString)); - }); - - serviceDescriptors.AddHangfireServer(); - serviceDescriptors.AddScoped(); - serviceDescriptors.AddScoped(); - - return serviceDescriptors; - } - - public static IServiceCollection AddStreamTicketService(this IServiceCollection serviceDescriptors, IConfiguration configuration) - { - serviceDescriptors - .AddOptionsWithValidateOnStart() - .Bind(configuration.GetRequiredSection(StreamTicketConfiguration.SectionName)) - .ValidateDataAnnotations(); - - serviceDescriptors.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService>().Value); - - serviceDescriptors.AddSingleton(); - return serviceDescriptors; - } - - public static IServiceCollection AddAudioTranscoder(this IServiceCollection serviceDescriptors, IConfiguration configuration) - { - serviceDescriptors - .AddOptionsWithValidateOnStart() - .Bind(configuration.GetRequiredSection(AudioConfiguration.SectionName)) - .ValidateDataAnnotations(); - - serviceDescriptors.AddSingleton(serviceProvider => - serviceProvider.GetRequiredService>().Value); - - serviceDescriptors.AddScoped(); - return serviceDescriptors; - } + serviceDescriptors.AddScoped(); + return serviceDescriptors; } -} \ No newline at end of file + + public static IServiceCollection AddPictureService(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors.AddScoped(); + return serviceDescriptors; + } + + public static IServiceCollection AddUploadIntentConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors.AddValidatedOptions(configuration, UploadIntentConfiguration.SectionName); + + return serviceDescriptors; + } + + public static IServiceCollection AddUploadIntentJobs(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors.AddUploadIntentConfiguration(configuration); + + serviceDescriptors.AddHostedService(); + serviceDescriptors.AddHostedService(); + + return serviceDescriptors; + } + + public static IServiceCollection AddDailyMixGenerationJob(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors.AddHangfire((serviceProvider, hangfireConfiguration) => + { + var databaseConfiguration = serviceProvider.GetRequiredService(); + hangfireConfiguration.UsePostgreSqlStorage(options => + options.UseNpgsqlConnection(databaseConfiguration.ConnectionString)); + }); + + serviceDescriptors.AddHangfireServer(); + serviceDescriptors.AddScoped(); + serviceDescriptors.AddScoped(); + + return serviceDescriptors; + } + + public static IServiceCollection AddStreamTicketService(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(StreamTicketConfiguration.SectionName)) + .ValidateDataAnnotations(); + + serviceDescriptors.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + serviceDescriptors.AddSingleton(); + return serviceDescriptors; + } + + public static IServiceCollection AddAudioTranscoder(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors + .AddOptionsWithValidateOnStart() + .Bind(configuration.GetRequiredSection(AudioConfiguration.SectionName)) + .ValidateDataAnnotations(); + + serviceDescriptors.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + serviceDescriptors.AddScoped(); + return serviceDescriptors; + } +} diff --git a/backend/Core/Musify.Infrastructure/Services/SingleFlightCache.cs b/backend/Core/Musify.Infrastructure/Services/SingleFlightCache.cs index 97693d1..681080c 100644 --- a/backend/Core/Musify.Infrastructure/Services/SingleFlightCache.cs +++ b/backend/Core/Musify.Infrastructure/Services/SingleFlightCache.cs @@ -2,40 +2,39 @@ using System.Collections.Concurrent; using ErrorOr; using Microsoft.Extensions.Caching.Memory; -namespace Musify.Infrastructure.Services +namespace Musify.Infrastructure.Services; + +public sealed class SingleFlightCache(IMemoryCache cache) { - public sealed class SingleFlightCache(IMemoryCache cache) + private readonly ConcurrentDictionary inFlight = new(); + + public async Task> GetOrCreateAsync( + string key, + TimeSpan duration, + Func>> factory) { - private readonly ConcurrentDictionary inFlight = new(); + if (duration <= TimeSpan.Zero) + return await factory(); - public async Task> GetOrCreateAsync( - string key, - TimeSpan duration, - Func>> factory) + if (cache.TryGetValue(key, out TValue? cached) && cached != null) + return cached; + + var pending = (Lazy>>)inFlight.GetOrAdd( + key, + _ => new Lazy>>(factory)); + + try { - if (duration <= TimeSpan.Zero) - return await factory(); + var result = await pending.Value; - if (cache.TryGetValue(key, out TValue? cached) && cached != null) - return cached; + if (!result.IsError) + cache.Set(key, result.Value, duration); - var pending = (Lazy>>)inFlight.GetOrAdd( - key, - _ => new Lazy>>(factory)); - - try - { - var result = await pending.Value; - - if (!result.IsError) - cache.Set(key, result.Value, duration); - - return result; - } - finally - { - inFlight.TryRemove(key, out _); - } + return result; + } + finally + { + inFlight.TryRemove(key, out _); } } } diff --git a/backend/Core/Musify.Infrastructure/Services/StorageService.cs b/backend/Core/Musify.Infrastructure/Services/StorageService.cs index 04a7034..8be9c1a 100644 --- a/backend/Core/Musify.Infrastructure/Services/StorageService.cs +++ b/backend/Core/Musify.Infrastructure/Services/StorageService.cs @@ -9,186 +9,121 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.Configuration; using ObjectMetaData = Musify.Application.Contracts.ObjectMetaData; -namespace Musify.Infrastructure.Services +namespace Musify.Infrastructure.Services; + +public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger logger, + InfrastructureStorageConfiguration storageClientConfiguration) + : IStorageService { - public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger logger, - InfrastructureStorageConfiguration storageClientConfiguration) - : IStorageService + public async Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken) { - public async Task GetFileAsync(string bucket, string key, CancellationToken cancellationToken) + var request = new GetObjectRequest { - var request = new GetObjectRequest - { - BucketName = bucket, - Key = key, - }; + BucketName = bucket, + Key = key, + }; - try - { - var response = await amazonS3.GetObjectAsync(request, cancellationToken); - logger.LogDebug("Retrieved file from S3 {Bucket}/{Key}", bucket, key); - return response.ResponseStream; - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to get file from S3 {Bucket}/{Key}", bucket, key); - throw; - } + try + { + var response = await amazonS3.GetObjectAsync(request, cancellationToken); + logger.LogDebug("Retrieved file from S3 {Bucket}/{Key}", bucket, key); + return response.ResponseStream; } - - public async Task GetUrlAsync(string bucket, string key, TimeSpan expirationTime, CancellationToken cancellationToken) + catch (Exception exception) { - var request = new GetPreSignedUrlRequest - { - BucketName = bucket, - Key = key, - Expires = DateTime.UtcNow + expirationTime, - Protocol = storageClientConfiguration.UseHttp ? Protocol.HTTP : Protocol.HTTPS - }; - - logger.LogDebug("Generating pre-signed URL for {Bucket}/{Key}", bucket, key); - return await amazonS3.GetPreSignedURLAsync(request); + logger.LogError(exception, "Failed to get file from S3 {Bucket}/{Key}", bucket, key); + throw; } + } - public async Task GetUploadUrlAsync( - string bucket, - string key, - string contentType, - TimeSpan expirationTime, - CancellationToken cancellationToken, - bool preventOverwrite = true) + public async Task GetUrlAsync(string bucket, string key, TimeSpan expirationTime, CancellationToken cancellationToken) + { + var request = new GetPreSignedUrlRequest { - var request = new GetPreSignedUrlRequest - { - BucketName = bucket, - Key = key, - Expires = DateTime.UtcNow + expirationTime, - Protocol = storageClientConfiguration.UseHttp ? Protocol.HTTP : Protocol.HTTPS, - Verb = HttpVerb.PUT, - ContentType = contentType - }; + BucketName = bucket, + Key = key, + Expires = DateTime.UtcNow + expirationTime, + Protocol = storageClientConfiguration.UseHttp ? Protocol.HTTP : Protocol.HTTPS + }; - if (preventOverwrite) - request.Headers["If-None-Match"] = "*"; + logger.LogDebug("Generating pre-signed URL for {Bucket}/{Key}", bucket, key); + return await amazonS3.GetPreSignedURLAsync(request); + } - 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, + CancellationToken cancellationToken, + bool preventOverwrite = true) + { + 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 HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken) + { + try + { + var response = await amazonS3.GetObjectMetadataAsync(bucket, key, cancellationToken); + return new ObjectMetaData(response.Headers.ContentType, response.ContentLength); } - - public async Task HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken) + catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) { - try - { - var response = await amazonS3.GetObjectMetadataAsync(bucket, key, cancellationToken); - return new ObjectMetaData(response.Headers.ContentType, response.ContentLength); - } - catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) - { - return null; - } - catch (Exception exception) - { - logger.LogError(exception, "HEAD failed for {Bucket}/{Key}", bucket, key); - throw; - } + return null; } - - public async Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken) + catch (Exception exception) { - var request = new DeleteObjectRequest - { - BucketName = bucket, - Key = key, - }; - - logger.LogDebug("Removing file from S3 {Bucket}/{Key}", bucket, key); - await amazonS3.DeleteObjectAsync(request, cancellationToken); + logger.LogError(exception, "HEAD failed for {Bucket}/{Key}", bucket, key); + throw; } + } - public async Task> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken) + public async Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken) + { + var request = new DeleteObjectRequest { - var trasnsferUtility = new TransferUtility(amazonS3); + BucketName = bucket, + Key = key, + }; - var files = Directory.GetFiles(sourceDirectory, "*"); + logger.LogDebug("Removing file from S3 {Bucket}/{Key}", bucket, key); + await amazonS3.DeleteObjectAsync(request, cancellationToken); + } - var uploadedKeys = new List(); - var successCount = 0; - var failedCount = 0; + public async Task> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken) + { + var trasnsferUtility = new TransferUtility(amazonS3); - foreach (var file in files) - { - string fileName = Path.GetFileName(file); - string filePath = Path.Combine(sourceDirectory, file); + var files = Directory.GetFiles(sourceDirectory, "*"); - var key = string.Join('/', new[] { route, fileName } - .Where(static s => !string.IsNullOrWhiteSpace(s)) - .Select(static s => s.Trim().Trim('/', '\\'))); - var contentType = MimeUtility.GetMimeMapping(filePath); - var upload = new Upload - { - Id = Guid.NewGuid(), - Bucket = bucket, - Key = key, - ContentType = contentType - }; - await database.Uploads.AddAsync(upload, cancellationToken); + var uploadedKeys = new List(); + var successCount = 0; + var failedCount = 0; - try - { - await trasnsferUtility.UploadAsync( - new TransferUtilityUploadRequest - { - FilePath = filePath, - BucketName = bucket, - Key = key, - ContentType = contentType - }, - cancellationToken); - upload.State = UploadState.Successful; - uploadedKeys.Add(key); - successCount++; - logger.LogDebug("Transferred file to S3 {Bucket}/{Key}", bucket, key); - } - catch (Exception exception) - { - failedCount++; - upload.State = UploadState.Failed; - logger.LogError(exception, "Failed to transfer file to S3 {Bucket}/{Key}", bucket, key); - throw; - } - } - - await database.SaveChangesAsync(cancellationToken); - logger.LogInformation("File transfer completed to {Bucket}/{Route}. Success: {SuccessCount}, Failed: {FailedCount}", bucket, route, successCount, failedCount); - - return uploadedKeys; - } - - public async Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken) + foreach (var file in files) { - try - { - using var fileStream = File.OpenRead(filePath); - var contentType = MimeUtility.GetMimeMapping(filePath); - await UploadFileAsync(fileStream, contentType, bucket, key, cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to upload file to S3 {Bucket}/{Key}", bucket, key); - throw; - } - } - - public async Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken) - { - var request = new PutObjectRequest - { - BucketName = bucket, - ContentType = contentType, - InputStream = sourceStream, - Key = key - }; + string fileName = Path.GetFileName(file); + string filePath = Path.Combine(sourceDirectory, file); + var key = string.Join('/', new[] { route, fileName } + .Where(static s => !string.IsNullOrWhiteSpace(s)) + .Select(static s => s.Trim().Trim('/', '\\'))); + var contentType = MimeUtility.GetMimeMapping(filePath); var upload = new Upload { Id = Guid.NewGuid(), @@ -200,105 +135,169 @@ namespace Musify.Infrastructure.Services try { - var response = await amazonS3.PutObjectAsync(request, cancellationToken); - logger.LogDebug("Uploaded file to S3 {Bucket}/{Key}", bucket, key); + await trasnsferUtility.UploadAsync( + new TransferUtilityUploadRequest + { + FilePath = filePath, + BucketName = bucket, + Key = key, + ContentType = contentType + }, + cancellationToken); + upload.State = UploadState.Successful; + uploadedKeys.Add(key); + successCount++; + logger.LogDebug("Transferred file to S3 {Bucket}/{Key}", bucket, key); } catch (Exception exception) { - logger.LogError(exception, "Failed to upload file to S3 {Bucket}/{Key}", bucket, key); + failedCount++; upload.State = UploadState.Failed; - await database.SaveChangesAsync(cancellationToken); - throw; - } - - upload.State = UploadState.Successful; - await database.SaveChangesAsync(cancellationToken); - logger.LogDebug("Saved upload record for {Bucket}/{Key}", bucket, key); - } - - public async Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, - CancellationToken cancellationToken) - { - var request = new CopyObjectRequest - { - SourceBucket = sourceBucket, - SourceKey = sourceKey, - DestinationBucket = destinationBucket, - DestinationKey = destinationKey - }; - - try - { - var response = await amazonS3.CopyObjectAsync(request, cancellationToken); - logger.LogInformation("Copied file from {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}", - sourceBucket, sourceKey, destinationBucket, destinationKey); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to copy file from {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}", - sourceBucket, sourceKey, destinationBucket, destinationKey); + logger.LogError(exception, "Failed to transfer file to S3 {Bucket}/{Key}", bucket, key); throw; } } - public async IAsyncEnumerable ListObjectsAsync( - string bucket, string prefix, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + await database.SaveChangesAsync(cancellationToken); + logger.LogInformation("File transfer completed to {Bucket}/{Route}. Success: {SuccessCount}, Failed: {FailedCount}", bucket, route, successCount, failedCount); + + return uploadedKeys; + } + + public async Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken) + { + try { - 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; - } - while (listResponse.IsTruncated.HasValue && listResponse.IsTruncated.Value); + using var fileStream = File.OpenRead(filePath); + var contentType = MimeUtility.GetMimeMapping(filePath); + await UploadFileAsync(fileStream, contentType, bucket, key, cancellationToken); } - - public async Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken) + catch (Exception exception) { - var prefix = folderKey.EndsWith('/') ? folderKey : $"{folderKey}/"; - var listRequest = new ListObjectsV2Request - { - BucketName = bucket, - Prefix = prefix - }; - - ListObjectsV2Response listResponse; - do - { - listResponse = await amazonS3.ListObjectsV2Async(listRequest, cancellationToken); - - if (listResponse.S3Objects == null|| listResponse.S3Objects.Count == 0) - break; - - var deleteRequest = new DeleteObjectsRequest - { - BucketName = bucket, - Objects = listResponse.S3Objects - .Select(o => new KeyVersion { Key = o.Key } ) - .ToList() - }; - - var deleteResponse = await amazonS3.DeleteObjectsAsync(deleteRequest, cancellationToken); - - var deleteErrors = deleteResponse.DeleteErrors ?? []; - if (deleteErrors.Count > 0) - { - var errors = string.Join(", ", deleteErrors.Select(e => $"{e.Key}: {e.Message}")); - throw new InvalidOperationException($"Failed to delete some objects in folder {folderKey}: {errors}"); - } - - listRequest.ContinuationToken = listResponse.NextContinuationToken; - - } while (listResponse.IsTruncated.HasValue && listResponse.IsTruncated.Value); + logger.LogError(exception, "Failed to upload file to S3 {Bucket}/{Key}", bucket, key); + throw; } } -} \ No newline at end of file + + public async Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken) + { + var request = new PutObjectRequest + { + BucketName = bucket, + ContentType = contentType, + InputStream = sourceStream, + Key = key + }; + + var upload = new Upload + { + Id = Guid.NewGuid(), + Bucket = bucket, + Key = key, + ContentType = contentType + }; + await database.Uploads.AddAsync(upload, cancellationToken); + + try + { + var response = await amazonS3.PutObjectAsync(request, cancellationToken); + logger.LogDebug("Uploaded file to S3 {Bucket}/{Key}", bucket, key); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to upload file to S3 {Bucket}/{Key}", bucket, key); + upload.State = UploadState.Failed; + await database.SaveChangesAsync(cancellationToken); + throw; + } + + upload.State = UploadState.Successful; + await database.SaveChangesAsync(cancellationToken); + logger.LogDebug("Saved upload record for {Bucket}/{Key}", bucket, key); + } + + public async Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, + CancellationToken cancellationToken) + { + var request = new CopyObjectRequest + { + SourceBucket = sourceBucket, + SourceKey = sourceKey, + DestinationBucket = destinationBucket, + DestinationKey = destinationKey + }; + + try + { + var response = await amazonS3.CopyObjectAsync(request, cancellationToken); + logger.LogInformation("Copied file from {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}", + sourceBucket, sourceKey, destinationBucket, destinationKey); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to copy file from {SourceBucket}/{SourceKey} to {DestinationBucket}/{DestinationKey}", + sourceBucket, sourceKey, destinationBucket, destinationKey); + throw; + } + } + + public async IAsyncEnumerable ListObjectsAsync( + string bucket, string prefix, [System.Runtime.CompilerServices.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; + } + while (listResponse.IsTruncated.HasValue && listResponse.IsTruncated.Value); + } + + public async Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken) + { + var prefix = folderKey.EndsWith('/') ? folderKey : $"{folderKey}/"; + var listRequest = new ListObjectsV2Request + { + BucketName = bucket, + Prefix = prefix + }; + + ListObjectsV2Response listResponse; + do + { + listResponse = await amazonS3.ListObjectsV2Async(listRequest, cancellationToken); + + if (listResponse.S3Objects == null || listResponse.S3Objects.Count == 0) + break; + + var deleteRequest = new DeleteObjectsRequest + { + BucketName = bucket, + Objects = listResponse.S3Objects + .Select(o => new KeyVersion { Key = o.Key }) + .ToList() + }; + + var deleteResponse = await amazonS3.DeleteObjectsAsync(deleteRequest, cancellationToken); + + var deleteErrors = deleteResponse.DeleteErrors ?? []; + if (deleteErrors.Count > 0) + { + var errors = string.Join(", ", deleteErrors.Select(e => $"{e.Key}: {e.Message}")); + throw new InvalidOperationException($"Failed to delete some objects in folder {folderKey}: {errors}"); + } + + listRequest.ContinuationToken = listResponse.NextContinuationToken; + + } while (listResponse.IsTruncated.HasValue && listResponse.IsTruncated.Value); + } +} diff --git a/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs b/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs index 67d6c16..f5c8d17 100644 --- a/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs +++ b/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs @@ -4,51 +4,50 @@ using Microsoft.IdentityModel.Tokens; using Musify.Application.Contracts; using Musify.Infrastructure.Configuration; -namespace Musify.Infrastructure.Services +namespace Musify.Infrastructure.Services; + +public sealed class StreamTicketService : IStreamTicketService, IDisposable { - public sealed class StreamTicketService : IStreamTicketService, IDisposable + private readonly StreamTicketConfiguration configuration; + private readonly RSA rsa; + private readonly SigningCredentials signingCredentials; + private readonly JsonWebTokenHandler tokenHandler = new(); + + public StreamTicketService(StreamTicketConfiguration configuration) { - private readonly StreamTicketConfiguration configuration; - private readonly RSA rsa; - private readonly SigningCredentials signingCredentials; - private readonly JsonWebTokenHandler tokenHandler = new(); + this.configuration = configuration; - public StreamTicketService(StreamTicketConfiguration configuration) - { - this.configuration = configuration; + rsa = RSA.Create(); + rsa.ImportFromPem(File.ReadAllText(configuration.PrivateKeyPath)); - 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(); - if (maxBytes != null) - claims["maxBytes"] = maxBytes.Value.ToString(); - - 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(); + 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(); + if (maxBytes != null) + claims["maxBytes"] = maxBytes.Value.ToString(); + + 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(); } diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props index b818461..1bd9491 100644 --- a/backend/Directory.Build.props +++ b/backend/Directory.Build.props @@ -4,6 +4,11 @@ net10.0 enable enable + latest-recommended + true + true + true + $(NoWarn);CS1591 diff --git a/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs b/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs index 04ea670..9e3c5bc 100644 --- a/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs +++ b/backend/Hosts/Musify.Api/Authentication/CurrentUser.cs @@ -39,4 +39,4 @@ public sealed class CurrentUser : IBindableFromHttpContext public static ValueTask BindAsync(HttpContext context, ParameterInfo parameter) => ValueTask.FromResult(new CurrentUser(context.User)); -} \ No newline at end of file +} diff --git a/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs b/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs index b8cd402..c43a3ac 100644 --- a/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs +++ b/backend/Hosts/Musify.Api/Authentication/JwtBearerEventsHandler.cs @@ -1,11 +1,10 @@ +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Json.Serialization; using Mediator; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.Caching.Memory; using Musify.Application.Users; -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Security.Claims; -using System.Text.Json.Serialization; namespace Musify.Api.Authentication; diff --git a/backend/Hosts/Musify.Api/Authentication/JwtBearerOptionsSetup.cs b/backend/Hosts/Musify.Api/Authentication/JwtBearerOptionsSetup.cs index c2b0ffe..db86913 100644 --- a/backend/Hosts/Musify.Api/Authentication/JwtBearerOptionsSetup.cs +++ b/backend/Hosts/Musify.Api/Authentication/JwtBearerOptionsSetup.cs @@ -1,7 +1,7 @@ +using System.Security.Claims; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; -using System.Security.Claims; namespace Musify.Api.Authentication; diff --git a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs index ac74df6..cd2fc5f 100644 --- a/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/LikeEndpoints.cs @@ -1,11 +1,10 @@ using Mediator; -using Musify.Application.Likes; -using Musify.Application.Shared; -using Musify.Application.Tracks.Responses; using Musify.Api.Authentication; using Musify.Api.DataTransferObjects.Likes; using Musify.Api.Extensions; -using Musify.Api.Filters; +using Musify.Application.Likes; +using Musify.Application.Shared; +using Musify.Application.Tracks.Responses; namespace Musify.Api.Endpoints; diff --git a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs index 8ab6dc2..be41042 100644 --- a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -1,14 +1,14 @@ using Mediator; -using Musify.Application.PlayLists; -using Musify.Application.PlayLists.Responses; -using Musify.Application.Tracks; -using Musify.Application.Tracks.Responses; -using Musify.Application.Shared; -using Musify.Application.Contracts; using Musify.Api.Authentication; using Musify.Api.DataTransferObjects.PlayLists; using Musify.Api.Extensions; using Musify.Api.Filters; +using Musify.Application.Contracts; +using Musify.Application.PlayLists; +using Musify.Application.PlayLists.Responses; +using Musify.Application.Shared; +using Musify.Application.Tracks; +using Musify.Application.Tracks.Responses; namespace Musify.Api.Endpoints; diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index eaa8325..f14b9f7 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -1,13 +1,13 @@ using Mediator; -using Musify.Domain.ValueObjects; -using Musify.Application.Tracks; -using Musify.Application.Tracks.Responses; -using Musify.Application.Shared; -using Musify.Application.Contracts; using Musify.Api.Authentication; using Musify.Api.DataTransferObjects.Tracks; using Musify.Api.Extensions; using Musify.Api.Filters; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Application.Tracks; +using Musify.Application.Tracks.Responses; +using Musify.Domain.ValueObjects; namespace Musify.Api.Endpoints; diff --git a/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs b/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs index 1ceb89e..da5c6d1 100644 --- a/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs +++ b/backend/Hosts/Musify.Api/Extensions/ServiceCollectionExtensions.cs @@ -1,12 +1,12 @@ using FluentValidation; -using Musify.Infrastructure.MassTransit; -using Musify.Infrastructure.Persistence; -using Musify.Application; -using Musify.Infrastructure.Services; using Musify.Api.Authentication; using Musify.Api.DataTransferObjects.PlayLists; using Musify.Api.OpenApi; using Musify.Api.Scalar; +using Musify.Application; +using Musify.Infrastructure.MassTransit; +using Musify.Infrastructure.Persistence; +using Musify.Infrastructure.Services; namespace Musify.Api.Extensions; diff --git a/backend/Hosts/Musify.Api/Filters/ValidationFilter.cs b/backend/Hosts/Musify.Api/Filters/ValidationFilter.cs index cd66124..04cadad 100644 --- a/backend/Hosts/Musify.Api/Filters/ValidationFilter.cs +++ b/backend/Hosts/Musify.Api/Filters/ValidationFilter.cs @@ -17,4 +17,4 @@ public sealed class ValidationFilter(IValidator validator) : IEndpointFilt ? await next(context) : Results.ValidationProblem(result.ToDictionary()); } -} \ No newline at end of file +} diff --git a/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs b/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs index ed9a5af..d70dfbc 100644 --- a/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs +++ b/backend/Hosts/Musify.Api/OpenApi/OpenApiOptionsSetup.cs @@ -37,7 +37,7 @@ public sealed class OpenApiOptionsSetup(IOptions op }; return Task.CompletedTask; - } ); + }); options.AddSchemaTransformer((schema, context, _) => { @@ -48,7 +48,7 @@ public sealed class OpenApiOptionsSetup(IOptions op } return Task.CompletedTask; - } ); + }); options.AddOperationTransformer((operation, context, _) => { @@ -70,9 +70,9 @@ public sealed class OpenApiOptionsSetup(IOptions op { [new OpenApiSecuritySchemeReference(SecuritySchemeId, context.Document)] = _configuration.Scopes.ToList(), - } ); + }); return Task.CompletedTask; - } ); + }); } } diff --git a/backend/Hosts/Musify.Api/Program.cs b/backend/Hosts/Musify.Api/Program.cs index 7e8772b..28d26c7 100644 --- a/backend/Hosts/Musify.Api/Program.cs +++ b/backend/Hosts/Musify.Api/Program.cs @@ -1,8 +1,8 @@ using System.Text.Json.Serialization; -using Musify.Infrastructure.Observability; -using Scalar.AspNetCore; using Musify.Api.Endpoints; using Musify.Api.Extensions; +using Musify.Infrastructure.Observability; +using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(); @@ -49,4 +49,4 @@ app app.Run(); -public partial class Program; \ No newline at end of file +public partial class Program; diff --git a/backend/Hosts/Musify.Api/Scalar/ScalarOptionsSetup.cs b/backend/Hosts/Musify.Api/Scalar/ScalarOptionsSetup.cs index 2ee2c0a..de89db5 100644 --- a/backend/Hosts/Musify.Api/Scalar/ScalarOptionsSetup.cs +++ b/backend/Hosts/Musify.Api/Scalar/ScalarOptionsSetup.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Options; -using Scalar.AspNetCore; using Musify.Api.Authentication; +using Scalar.AspNetCore; namespace Musify.Api.Scalar; @@ -26,7 +26,7 @@ public sealed class ScalarOptionsSetup(IOptions opt flow.SelectedScopes = _configuration.Scopes; flow.RedirectUri = _configuration.ScalarRedirectUri; flow.RefreshUrl = _configuration.TokenEndpoint; - } ) + }) .HideModels(); } } diff --git a/backend/Hosts/Musify.StreamingGateway/Program.cs b/backend/Hosts/Musify.StreamingGateway/Program.cs index 0ee5a1c..464f784 100644 --- a/backend/Hosts/Musify.StreamingGateway/Program.cs +++ b/backend/Hosts/Musify.StreamingGateway/Program.cs @@ -24,7 +24,7 @@ builder.Services.AddCors(options => options.AddPolicy(corsPolicy, policy => policy.AllowAnyHeader() .WithMethods("GET", "HEAD") .WithExposedHeaders("Content-Range", "Accept-Ranges", "Content-Length"); -} )); +})); builder.Services .AddReverseProxy() diff --git a/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs b/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs index aecba63..981dda9 100644 --- a/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs +++ b/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs @@ -3,83 +3,82 @@ using Microsoft.AspNetCore.Http; using Musify.Api.Authentication; using Xunit; -namespace Musify.Api.Tests.Authentication +namespace Musify.Api.Tests.Authentication; + +public sealed class CurrentUserTests { - public sealed class CurrentUserTests + private static async Task BindAsync(ClaimsPrincipal principal) { - private static async Task BindAsync(ClaimsPrincipal principal) - { - var context = new DefaultHttpContext { User = principal }; - var bound = await CurrentUser.BindAsync(context, parameter: null!); - return bound!; - } + var context = new DefaultHttpContext { User = principal }; + var bound = await CurrentUser.BindAsync(context, parameter: null!); + return bound!; + } - private static ClaimsPrincipal AuthenticatedPrincipal(params Claim[] claims) => - new(new ClaimsIdentity(claims, authenticationType: "TestAuth")); + private static ClaimsPrincipal AuthenticatedPrincipal(params Claim[] claims) => + new(new ClaimsIdentity(claims, authenticationType: "TestAuth")); - [Fact] - public async Task BindAsync_AuthenticatedPrincipalWithValidClaims_PopulatesEveryField() - { - var principal = AuthenticatedPrincipal( - new Claim(ClaimTypes.NameIdentifier, "42"), - new Claim(ClaimTypes.Name, "jdoe"), - new Claim(ClaimTypes.GivenName, "Jane"), - new Claim(ClaimTypes.Surname, "Doe")); + [Fact] + public async Task BindAsync_AuthenticatedPrincipalWithValidClaims_PopulatesEveryField() + { + var principal = AuthenticatedPrincipal( + new Claim(ClaimTypes.NameIdentifier, "42"), + new Claim(ClaimTypes.Name, "jdoe"), + new Claim(ClaimTypes.GivenName, "Jane"), + new Claim(ClaimTypes.Surname, "Doe")); - var user = await BindAsync(principal); + var user = await BindAsync(principal); - Assert.True(user.IsAuthenticated); - Assert.Equal(42, user.Id); - Assert.Equal(42, user.RequiredId); - Assert.Equal("jdoe", user.Username); - Assert.Equal("Jane", user.FirstName); - Assert.Equal("Doe", user.LastName); - } + Assert.True(user.IsAuthenticated); + Assert.Equal(42, user.Id); + Assert.Equal(42, user.RequiredId); + Assert.Equal("jdoe", user.Username); + Assert.Equal("Jane", user.FirstName); + Assert.Equal("Doe", user.LastName); + } - [Fact] - public async Task BindAsync_AnonymousPrincipal_IsNotAuthenticatedAndHasNoId() - { - var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity())); + [Fact] + public async Task BindAsync_AnonymousPrincipal_IsNotAuthenticatedAndHasNoId() + { + var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity())); - Assert.False(user.IsAuthenticated); - Assert.Null(user.Id); - } + Assert.False(user.IsAuthenticated); + Assert.Null(user.Id); + } - [Theory] - [InlineData("not-a-number")] - [InlineData("0")] - [InlineData("")] - public async Task BindAsync_NonNumericOrZeroNameIdentifier_LeavesIdNull(string rawId) - { - var user = await BindAsync(AuthenticatedPrincipal(new Claim(ClaimTypes.NameIdentifier, rawId))); + [Theory] + [InlineData("not-a-number")] + [InlineData("0")] + [InlineData("")] + public async Task BindAsync_NonNumericOrZeroNameIdentifier_LeavesIdNull(string rawId) + { + var user = await BindAsync(AuthenticatedPrincipal(new Claim(ClaimTypes.NameIdentifier, rawId))); - Assert.Null(user.Id); - } + Assert.Null(user.Id); + } - [Fact] - public async Task RequiredId_MissingId_Throws() - { - var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity())); + [Fact] + public async Task RequiredId_MissingId_Throws() + { + var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity())); - Assert.Throws(() => user.RequiredId); - } + Assert.Throws(() => user.RequiredId); + } - [Fact] - public async Task HasClaim_ExistingClaimType_ReturnsTrue() - { - var user = await BindAsync(AuthenticatedPrincipal(new Claim("role", "admin"))); + [Fact] + public async Task HasClaim_ExistingClaimType_ReturnsTrue() + { + var user = await BindAsync(AuthenticatedPrincipal(new Claim("role", "admin"))); - Assert.True(user.HasClaim("role")); - Assert.False(user.HasClaim("missing")); - } + Assert.True(user.HasClaim("role")); + Assert.False(user.HasClaim("missing")); + } - [Fact] - public async Task FindClaim_ExistingClaimType_ReturnsItsValue() - { - var user = await BindAsync(AuthenticatedPrincipal(new Claim("picture", "https://avatar.example/jane.png"))); + [Fact] + public async Task FindClaim_ExistingClaimType_ReturnsItsValue() + { + var user = await BindAsync(AuthenticatedPrincipal(new Claim("picture", "https://avatar.example/jane.png"))); - Assert.Equal("https://avatar.example/jane.png", user.FindClaim("picture")); - Assert.Null(user.FindClaim("missing")); - } + Assert.Equal("https://avatar.example/jane.png", user.FindClaim("picture")); + Assert.Null(user.FindClaim("missing")); } } diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs index 9230f5b..92a1e15 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs @@ -8,129 +8,128 @@ using Musify.Application.Contracts; using NSubstitute; using Xunit; -namespace Musify.Api.Tests.Endpoints +namespace Musify.Api.Tests.Endpoints; + +[Collection(ApiCollection.Name)] +public sealed class AlbumEndpointsTests(ApiTestFixture fixture) { - [Collection(ApiCollection.Name)] - public sealed class AlbumEndpointsTests(ApiTestFixture fixture) + private async Task CreateUserAsync() { - private async Task CreateUserAsync() - { - var userId = Random.Shared.NextInt64(1, long.MaxValue); - var response = await fixture.CreateAnonymousClient() - .PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null)); - response.EnsureSuccessStatusCode(); - return userId; - } + var userId = Random.Shared.NextInt64(1, long.MaxValue); + var response = await fixture.CreateAnonymousClient() + .PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null)); + response.EnsureSuccessStatusCode(); + return userId; + } - private async Task CreatePictureIntentAsync(HttpClient client) - { - fixture.StorageService - .GetUploadUrlAsync(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 async Task CreatePictureIntentAsync(HttpClient client) + { + fixture.StorageService + .GetUploadUrlAsync(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 response = await client.PostAsJsonAsync( - "/albums/upload-picture", - new RequestAlbumPictureUploadRequest("webp", "image/webp"), - TestContext.Current.CancellationToken); - response.EnsureSuccessStatusCode(); + var response = await client.PostAsJsonAsync( + "/albums/upload-picture", + new RequestAlbumPictureUploadRequest("webp", "image/webp"), + TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); - var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - return body!.IntentId; - } + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + return body!.IntentId; + } - [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_NoAuthHeader_ReturnsUnauthorized() + { + var response = await fixture.CreateAnonymousClient() + .PostAsJsonAsync("/albums", new CreateAlbumRequest("Unauthorized Album", null, null, Guid.NewGuid()), TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } - [Fact] - public async Task PostAlbums_AuthenticatedExistingUser_ReturnsCreatedAlbum() - { - var userId = await CreateUserAsync(); - var client = fixture.CreateAuthenticatedClient(userId); - var pictureIntentId = await CreatePictureIntentAsync(client); + [Fact] + public async Task PostAlbums_AuthenticatedExistingUser_ReturnsCreatedAlbum() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); + var pictureIntentId = await CreatePictureIntentAsync(client); - var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024, pictureIntentId), TestContext.Current.CancellationToken); + var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024, pictureIntentId), TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.NotNull(body); - Assert.Equal("My Album", body.Title); - Assert.Equal(userId, body.OwnerUserId); - } + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(body); + Assert.Equal("My Album", body.Title); + Assert.Equal(userId, body.OwnerUserId); + } - [Fact] - public async Task PostAlbums_BlankTitle_ReturnsValidationProblem() - { - var userId = await CreateUserAsync(); - var client = fixture.CreateAuthenticatedClient(userId); + [Fact] + public async Task PostAlbums_BlankTitle_ReturnsValidationProblem() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); - var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("", null, null, Guid.NewGuid()), TestContext.Current.CancellationToken); + var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("", null, null, Guid.NewGuid()), TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } - [Fact] - public async Task PostAlbums_MissingPictureIntent_ReturnsValidationProblem() - { - var userId = await CreateUserAsync(); - var client = fixture.CreateAuthenticatedClient(userId); + [Fact] + public async Task PostAlbums_MissingPictureIntent_ReturnsValidationProblem() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); - var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("No Picture", null, null, Guid.Empty), TestContext.Current.CancellationToken); + var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("No Picture", null, null, Guid.Empty), TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } - [Fact] - public async Task GetAlbumById_AfterCreate_ReturnsIt() - { - var userId = await CreateUserAsync(); - var client = fixture.CreateAuthenticatedClient(userId); - var 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); + [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); - var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); + var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - } + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } - [Fact] - public async Task DeleteAlbum_NotTheOwner_ReturnsUnauthorized() - { - var owner = await CreateUserAsync(); - var stranger = await CreateUserAsync(); - var 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); + [Fact] + public async Task DeleteAlbum_NotTheOwner_ReturnsUnauthorized() + { + 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); - var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); + var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - } + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } - [Fact] - public async Task DeleteAlbum_Owner_ReturnsNoContentAndAlbumIsGone() - { - var userId = await CreateUserAsync(); - var client = fixture.CreateAuthenticatedClient(userId); - var 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); + [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); - var deleteResponse = await client.DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); - var getResponse = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created.Id}", TestContext.Current.CancellationToken); + var deleteResponse = await client.DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken); + var getResponse = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created.Id}", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); - Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode); - } + Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode); } } diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/ConfigEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/ConfigEndpointsTests.cs index 41ec394..4f7f29c 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/ConfigEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/ConfigEndpointsTests.cs @@ -4,22 +4,21 @@ using Musify.Api.Tests.TestSupport; using Musify.Application.Configuration.Responses; using Xunit; -namespace Musify.Api.Tests.Endpoints +namespace Musify.Api.Tests.Endpoints; + +[Collection(ApiCollection.Name)] +public sealed class ConfigEndpointsTests(ApiTestFixture fixture) { - [Collection(ApiCollection.Name)] - public sealed class ConfigEndpointsTests(ApiTestFixture fixture) + [Fact] + public async Task GetPlaybackConfig_WithoutAuthentication_ReturnsConfiguredValues() { - [Fact] - public async Task GetPlaybackConfig_WithoutAuthentication_ReturnsConfiguredValues() - { - var client = fixture.CreateAnonymousClient(); + 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 539cd97..3292381 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs @@ -11,96 +11,95 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.Persistence; using Xunit; -namespace Musify.Api.Tests.Endpoints +namespace Musify.Api.Tests.Endpoints; + +[Collection(ApiCollection.Name)] +public sealed class GenreEndpointsTests(ApiTestFixture fixture) { - [Collection(ApiCollection.Name)] - public sealed class GenreEndpointsTests(ApiTestFixture fixture) + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + 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 { - Converters = { new JsonStringEnumConverter() } + 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); + database.UserHasTracks.Add(new UserHasTrack { UserId = owner.Id, TrackId = track.Id }); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + return title; + } - 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); - database.UserHasTracks.Add(new UserHasTrack { UserId = owner.Id, TrackId = track.Id }); - await database.SaveChangesAsync(TestContext.Current.CancellationToken); - return title; - } + [Fact] + public async Task GetGenres_Anonymous_ListsGenresInUse() + { + await SeedTrackAsync(Genre.Funk); - [Fact] - public async Task GetGenres_Anonymous_ListsGenresInUse() - { - await SeedTrackAsync(Genre.Funk); + var response = await fixture.CreateAnonymousClient().GetAsync("/genres", TestContext.Current.CancellationToken); - var response = await fixture.CreateAnonymousClient().GetAsync("/genres", 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.Contains(body!, g => g.Genre == Genre.Funk && g.TrackCount >= 1); - } + [Fact] + public async Task GetAvailableGenres_Anonymous_ListsEveryGenreWithIncompatibilities() + { + var response = await fixture.CreateAnonymousClient().GetAsync("/genres/available", 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.Equal(Enum.GetValues().Length, body!.Count); + Assert.Contains(Genre.Lofi, body.Single(g => g.Genre == Genre.Metal).IncompatibleWith); + } - 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); - } + [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); - [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.BadRequest, response.StatusCode); + } - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } + [Fact] + public async Task GetTracks_GenreQuery_IsCaseInsensitive() + { + var title = await SeedTrackAsync(Genre.Blues); - [Fact] - public async Task GetTracks_GenreQuery_IsCaseInsensitive() - { - var title = await SeedTrackAsync(Genre.Blues); + var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=blues&pageSize=100", TestContext.Current.CancellationToken); - var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=blues&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.Track.Title == title); + } - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken); - Assert.Contains(body!.Items, item => item.Track.Title == title); - } + [Fact] + public async Task GetTracks_GenreQuery_FiltersByTag() + { + var title = await SeedTrackAsync(Genre.Ambient); + await SeedTrackAsync(Genre.Techno); - [Fact] - public async Task GetTracks_GenreQuery_FiltersByTag() - { - var title = await SeedTrackAsync(Genre.Ambient); - await SeedTrackAsync(Genre.Techno); + var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=Ambient&pageSize=100", TestContext.Current.CancellationToken); - 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.Track.Title == title); - Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Track.Tags)); - } + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken); + Assert.Contains(body!.Items, item => item.Track.Title == title); + Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Track.Tags)); } } diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs index 01cdda4..41f3e4e 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs @@ -4,106 +4,105 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Musify.Api.DataTransferObjects.Tracks; using Musify.Api.DataTransferObjects.Users; +using Musify.Api.Tests.TestSupport; using Musify.Domain.Entities; using Musify.Infrastructure.Persistence; using Xunit; -using Musify.Api.Tests.TestSupport; -namespace Musify.Api.Tests.Endpoints +namespace Musify.Api.Tests.Endpoints; + +[Collection(ApiCollection.Name)] +public sealed class ListeningProgressEndpointsTests(ApiTestFixture fixture) { - [Collection(ApiCollection.Name)] - public sealed class ListeningProgressEndpointsTests(ApiTestFixture fixture) + private async Task CreateUserAsync() { - private async Task CreateUserAsync() + var userId = Random.Shared.NextInt64(1, long.MaxValue); + var response = await fixture.CreateAnonymousClient().PostAsJsonAsync( + "/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + return userId; + } + + private async Task SeedListenAsync(long userId, double durationSeconds) + { + 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 { - var userId = Random.Shared.NextInt64(1, long.MaxValue); - var response = await fixture.CreateAnonymousClient().PostAsJsonAsync( - "/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken); - response.EnsureSuccessStatusCode(); - return userId; - } - - 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; - } + 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) - { - 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/UserEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs index e398f2e..c4df797 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs @@ -12,125 +12,124 @@ using Musify.Domain.Entities; using Musify.Infrastructure.Persistence; using Xunit; -namespace Musify.Api.Tests.Endpoints +namespace Musify.Api.Tests.Endpoints; + +[Collection(ApiCollection.Name)] +public sealed class UserEndpointsTests(ApiTestFixture fixture) { - [Collection(ApiCollection.Name)] - public sealed class UserEndpointsTests(ApiTestFixture fixture) + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + 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 { - Converters = { new JsonStringEnumConverter() } + Title = trackTitle, + NormalizedTitle = trackTitle.ToUpperInvariant(), + OwnerUserId = owner.Id, + Owner = owner }; - - private async Task SeedListenAsync(long userId, string trackTitle, DateTime listenedAt) + database.Tracks.Add(track); + database.ListeningHistories.Add(new ListeningHistory { - 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 - }); - await database.SaveChangesAsync(TestContext.Current.CancellationToken); - } + UserId = userId, + TrackId = track.Id, + ListenedAt = listenedAt + }); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + } - [Fact] - public async Task PostUsers_NewUser_ReturnsCreatedWithLocationHeader() - { - var client = fixture.CreateAnonymousClient(); - var userId = Random.Shared.NextInt64(1, long.MaxValue); + [Fact] + public async Task PostUsers_NewUser_ReturnsCreatedWithLocationHeader() + { + var client = fixture.CreateAnonymousClient(); + var userId = Random.Shared.NextInt64(1, long.MaxValue); - var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Jane Doe", "Jane", "Doe"), TestContext.Current.CancellationToken); + var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Jane Doe", "Jane", "Doe"), TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - Assert.Equal($"/users/{userId}", response.Headers.Location?.OriginalString); - var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.NotNull(body); - Assert.Equal("Jane Doe", body.Name); - } + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal($"/users/{userId}", response.Headers.Location?.OriginalString); + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(body); + Assert.Equal("Jane Doe", body.Name); + } - [Fact] - public async Task PostUsers_BlankName_ReturnsValidationProblem() - { - var client = fixture.CreateAnonymousClient(); + [Fact] + public async Task PostUsers_BlankName_ReturnsValidationProblem() + { + var client = fixture.CreateAnonymousClient(); - var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(Random.Shared.NextInt64(1, long.MaxValue), "", null, null), TestContext.Current.CancellationToken); + var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(Random.Shared.NextInt64(1, long.MaxValue), "", null, null), TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - } + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } - [Fact] - public async Task PostUsers_DuplicateId_ReturnsConflict() - { - var client = fixture.CreateAnonymousClient(); - var userId = Random.Shared.NextInt64(1, long.MaxValue); - await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "First", null, null), TestContext.Current.CancellationToken); + [Fact] + public async Task PostUsers_DuplicateId_ReturnsConflict() + { + var client = fixture.CreateAnonymousClient(); + var userId = Random.Shared.NextInt64(1, long.MaxValue); + await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "First", null, null), TestContext.Current.CancellationToken); - var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Second", null, null), TestContext.Current.CancellationToken); + var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Second", null, null), TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); - } + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } - [Fact] - public async Task GetUserById_ExistingUser_ReturnsIt() - { - var client = fixture.CreateAnonymousClient(); - var userId = Random.Shared.NextInt64(1, long.MaxValue); - await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Findable User", null, null), TestContext.Current.CancellationToken); + [Fact] + public async Task GetUserById_ExistingUser_ReturnsIt() + { + var client = fixture.CreateAnonymousClient(); + var userId = Random.Shared.NextInt64(1, long.MaxValue); + await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Findable User", null, null), TestContext.Current.CancellationToken); - var response = await client.GetAsync($"/users/{userId}", TestContext.Current.CancellationToken); + var response = await client.GetAsync($"/users/{userId}", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.Equal("Findable User", body?.Name); - } + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.Equal("Findable User", body?.Name); + } - [Fact] - public async Task GetUserById_MissingUser_ReturnsNotFound() - { - var client = fixture.CreateAnonymousClient(); + [Fact] + public async Task GetUserById_MissingUser_ReturnsNotFound() + { + var client = fixture.CreateAnonymousClient(); - var response = await client.GetAsync($"/users/{Random.Shared.NextInt64(1, long.MaxValue)}", TestContext.Current.CancellationToken); + var response = await client.GetAsync($"/users/{Random.Shared.NextInt64(1, long.MaxValue)}", TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } - [Fact] - public async Task GetLastTrackListenedByUserId_HasHistory_ReturnsMostRecentTrack() - { - var client = fixture.CreateAnonymousClient(); - var userId = Random.Shared.NextInt64(1, long.MaxValue); - await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken); - 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 client.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken); + 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_ReturnsNotFound() - { - var client = fixture.CreateAnonymousClient(); - var userId = Random.Shared.NextInt64(1, long.MaxValue); - await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken); + [Fact] + public async Task GetLastTrackListenedByUserId_NoHistory_ReturnsNotFound() + { + var client = fixture.CreateAnonymousClient(); + var userId = Random.Shared.NextInt64(1, long.MaxValue); + await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken); - 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.NotFound, response.StatusCode); - } + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } } diff --git a/backend/Tests/Musify.Api.Tests/Extensions/ErrorOrHttpExtensionsTests.cs b/backend/Tests/Musify.Api.Tests/Extensions/ErrorOrHttpExtensionsTests.cs index 773c1ca..8b6320a 100644 --- a/backend/Tests/Musify.Api.Tests/Extensions/ErrorOrHttpExtensionsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Extensions/ErrorOrHttpExtensionsTests.cs @@ -4,133 +4,132 @@ using Microsoft.Extensions.DependencyInjection; using Musify.Api.Extensions; using Xunit; -namespace Musify.Api.Tests.Extensions +namespace Musify.Api.Tests.Extensions; + +public sealed class ErrorOrHttpExtensionsTests { - public sealed class ErrorOrHttpExtensionsTests + private static readonly IServiceProvider Services = new ServiceCollection().AddLogging().AddProblemDetails().BuildServiceProvider(); + + private static async Task<(int StatusCode, string Body)> ExecuteAsync(IResult result) { - private static readonly IServiceProvider Services = new ServiceCollection().AddLogging().AddProblemDetails().BuildServiceProvider(); + var context = new DefaultHttpContext { RequestServices = Services }; + var bodyStream = new MemoryStream(); + context.Response.Body = bodyStream; - private static async Task<(int StatusCode, string Body)> ExecuteAsync(IResult result) + 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 { - var context = new DefaultHttpContext { RequestServices = Services }; - var bodyStream = new MemoryStream(); - context.Response.Body = bodyStream; + Error.Validation("Name", "Name is required"), + Error.Validation("Name", "Name is too long"), + Error.Validation("Age", "Age must be positive"), + }; - await result.ExecuteAsync(context); + var (statusCode, body) = await ExecuteAsync(result.ToHttpResult()); - bodyStream.Position = 0; - using var reader = new StreamReader(bodyStream); - return (context.Response.StatusCode, await reader.ReadToEndAsync()); - } + Assert.Equal(StatusCodes.Status400BadRequest, statusCode); + Assert.Contains("Name is required", body); + Assert.Contains("Age must be positive", body); + } - [Fact] - public async Task ToHttpResult_Success_ReturnsNoContent() - { - ErrorOr result = new Success(); + [Fact] + public async Task ToCreatedResult_Success_ReturnsCreatedAtTheGivenLocation() + { + ErrorOr result = "the-id"; - var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); + var context = new DefaultHttpContext { RequestServices = Services, Response = { Body = new MemoryStream() } }; + await result.ToCreatedResult(value => $"/things/{value}").ExecuteAsync(context); - Assert.Equal(StatusCodes.Status204NoContent, statusCode); - } + Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode); + Assert.Equal("/things/the-id", context.Response.Headers.Location); + } - [Fact] - public async Task ToHttpResult_Value_ReturnsOkWithTheValue() - { - ErrorOr result = "hello"; + [Fact] + public async Task ToCreatedResult_Error_ReturnsTheMappedErrorStatus() + { + ErrorOr result = Error.Conflict(); - var (statusCode, body) = await ExecuteAsync(result.ToHttpResult()); + var (statusCode, _) = await ExecuteAsync(result.ToCreatedResult(value => $"/things/{value}")); - 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); - } + 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 8b16cfe..18ecdcc 100644 --- a/backend/Tests/Musify.Api.Tests/Filters/ValidationFilterTests.cs +++ b/backend/Tests/Musify.Api.Tests/Filters/ValidationFilterTests.cs @@ -6,57 +6,56 @@ using Musify.Api.Filters; using NSubstitute; using Xunit; -namespace Musify.Api.Tests.Filters +namespace Musify.Api.Tests.Filters; + +public sealed class ValidationFilterTests { - public sealed class ValidationFilterTests + 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 record Payload(string Name); + var validator = Substitute.For>(); + validator.ValidateAsync(Arg.Any(), Arg.Any()).Returns(new ValidationResult()); + var filter = new ValidationFilter(validator); + var context = CreateContext(new Payload("ok")); - private static EndpointFilterInvocationContext CreateContext(T argument) => - EndpointFilterInvocationContext.Create(new DefaultHttpContext(), argument); + var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult("next-called")); - [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")); + Assert.Equal("next-called", result); + await validator.Received(1).ValidateAsync(Arg.Is(p => p.Name == "ok"), Arg.Any()); + } - var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult("next-called")); + [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; - Assert.Equal("next-called", result); - await validator.Received(1).ValidateAsync(Arg.Is(p => p.Name == "ok"), Arg.Any()); - } + var result = await filter.InvokeAsync(context, _ => { nextCalled = true; return ValueTask.FromResult(null); }); - [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; + Assert.False(nextCalled); + var problem = Assert.IsType(result); + Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode); + } - var result = await filter.InvokeAsync(context, _ => { nextCalled = true; return ValueTask.FromResult(null); }); + [Fact] + public async Task InvokeAsync_NoMatchingArgument_CallsNextWithoutValidating() + { + var validator = Substitute.For>(); + var filter = new ValidationFilter(validator); + var context = CreateContext(42); - Assert.False(nextCalled); - var problem = Assert.IsType(result); - Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode); - } + var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult("next-called")); - [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()); - } + 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 c0272af..5d44ceb 100644 --- a/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs +++ b/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs @@ -11,92 +11,91 @@ using NSubstitute; using Testcontainers.PostgreSql; using Xunit; -namespace Musify.Api.Tests.TestSupport +namespace Musify.Api.Tests.TestSupport; + +public sealed class ApiTestFixture : WebApplicationFactory, IAsyncLifetime { - public sealed class ApiTestFixture : WebApplicationFactory, IAsyncLifetime + private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine") + .WithDatabase("musify_api_test") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + + public IStorageService StorageService { get; } = Substitute.For(); + + public IEventBus EventBus { get; } = Substitute.For(); + + public IStreamTicketService StreamTicketService { get; } = Substitute.For(); + + public async ValueTask InitializeAsync() { - private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine") - .WithDatabase("musify_api_test") - .WithUsername("postgres") - .WithPassword("postgres") - .Build(); + await postgres.StartAsync(); - public IStorageService StorageService { get; } = Substitute.For(); - - public IEventBus EventBus { get; } = Substitute.For(); - - public IStreamTicketService StreamTicketService { get; } = Substitute.For(); - - public async ValueTask InitializeAsync() - { - await postgres.StartAsync(); - - using var scope = Services.CreateScope(); - var database = scope.ServiceProvider.GetRequiredService(); - await database.Database.MigrateAsync(); - } - - public override async ValueTask DisposeAsync() - { - await base.DisposeAsync(); - await postgres.DisposeAsync(); - } - - public HttpClient CreateAnonymousClient() => CreateClient(); - - public HttpClient CreateAuthenticatedClient(long userId, string userName = "test-user") - { - var client = CreateClient(); - client.DefaultRequestHeaders.Add(FakeAuthenticationHandler.UserIdHeader, userId.ToString()); - client.DefaultRequestHeaders.Add("X-Test-User-Name", userName); - return client; - } - - protected override void ConfigureWebHost(IWebHostBuilder builder) - { - builder.ConfigureAppConfiguration((_, configuration) => - { - configuration.AddInMemoryCollection(new Dictionary - { - ["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, _ => { }); - }); - } + using var scope = Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + await database.Database.MigrateAsync(); } - [CollectionDefinition(Name)] - public sealed class ApiCollection : ICollectionFixture + public override async ValueTask DisposeAsync() { - public const string Name = "Api"; + await base.DisposeAsync(); + await postgres.DisposeAsync(); + } + + public HttpClient CreateAnonymousClient() => CreateClient(); + + public HttpClient CreateAuthenticatedClient(long userId, string userName = "test-user") + { + var client = CreateClient(); + client.DefaultRequestHeaders.Add(FakeAuthenticationHandler.UserIdHeader, userId.ToString()); + client.DefaultRequestHeaders.Add("X-Test-User-Name", userName); + return client; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureAppConfiguration((_, configuration) => + { + configuration.AddInMemoryCollection(new Dictionary + { + ["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"; +} diff --git a/backend/Tests/Musify.Api.Tests/TestSupport/FakeAuthenticationHandler.cs b/backend/Tests/Musify.Api.Tests/TestSupport/FakeAuthenticationHandler.cs index 497bc7b..c61ea8e 100644 --- a/backend/Tests/Musify.Api.Tests/TestSupport/FakeAuthenticationHandler.cs +++ b/backend/Tests/Musify.Api.Tests/TestSupport/FakeAuthenticationHandler.cs @@ -4,29 +4,28 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace Musify.Api.Tests.TestSupport +namespace Musify.Api.Tests.TestSupport; + +public sealed class FakeAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : AuthenticationHandler(options, logger, encoder) { - public sealed class FakeAuthenticationHandler( - IOptionsMonitor options, - ILoggerFactory logger, - UrlEncoder encoder) - : AuthenticationHandler(options, logger, encoder) + public const string SchemeName = "Test"; + public const string UserIdHeader = "X-Test-User-Id"; + + protected override Task HandleAuthenticateAsync() { - public const string SchemeName = "Test"; - public const string UserIdHeader = "X-Test-User-Id"; + if (!Request.Headers.TryGetValue(UserIdHeader, out var userId) || string.IsNullOrEmpty(userId)) + return Task.FromResult(AuthenticateResult.NoResult()); - protected override Task HandleAuthenticateAsync() - { - if (!Request.Headers.TryGetValue(UserIdHeader, out var userId) || string.IsNullOrEmpty(userId)) - return Task.FromResult(AuthenticateResult.NoResult()); + 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 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)); - } + 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 c2d3ee1..7fe97e9 100644 --- a/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs +++ b/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs @@ -2,84 +2,83 @@ using Musify.Api.DataTransferObjects.Albums; using Musify.Api.Validators.Albums; using Xunit; -namespace Musify.Api.Tests.Validators +namespace Musify.Api.Tests.Validators; + +public sealed class CreateAlbumRequestValidatorTests { - public sealed class CreateAlbumRequestValidatorTests + private readonly CreateAlbumRequestValidator validator = new(); + + [Fact] + public void Validate_ValidRequest_Passes() { - private readonly CreateAlbumRequestValidator validator = new(); + var result = validator.Validate(new CreateAlbumRequest("My Album", "A short description", 2024, Guid.NewGuid())); - [Fact] - public void Validate_ValidRequest_Passes() - { - var result = validator.Validate(new CreateAlbumRequest("My Album", "A short description", 2024, 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_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_BlankTitle_Fails() + { + var result = validator.Validate(new CreateAlbumRequest("", null, null, Guid.NewGuid())); - [Fact] - public void Validate_BlankTitle_Fails() - { - var result = validator.Validate(new CreateAlbumRequest("", null, null, Guid.NewGuid())); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.Title)); + } - Assert.False(result.IsValid); - Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.Title)); - } + [Fact] + public void Validate_TitleOverMaxLength_Fails() + { + var result = validator.Validate(new CreateAlbumRequest(new string('a', 201), null, null, 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.False(result.IsValid); - } + [Fact] + public void Validate_ReleaseYearBeforeTheEarliestBoundary_Fails() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest - 1, Guid.NewGuid())); - [Fact] - public void Validate_ReleaseYearBeforeTheEarliestBoundary_Fails() - { - var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest - 1, Guid.NewGuid())); + Assert.False(result.IsValid); + } - Assert.False(result.IsValid); - } + [Fact] + public void Validate_ReleaseYearAtTheEarliestBoundary_Passes() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest, Guid.NewGuid())); - [Fact] - public void Validate_ReleaseYearAtTheEarliestBoundary_Passes() - { - var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Earliest, Guid.NewGuid())); + Assert.True(result.IsValid); + } - Assert.True(result.IsValid); - } + [Fact] + public void Validate_ReleaseYearAtTheLatestBoundary_Passes() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest, Guid.NewGuid())); - [Fact] - public void Validate_ReleaseYearAtTheLatestBoundary_Passes() - { - var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest, Guid.NewGuid())); + Assert.True(result.IsValid); + } - Assert.True(result.IsValid); - } + [Fact] + public void Validate_ReleaseYearPastTheLatestBoundary_Fails() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest + 1, Guid.NewGuid())); - [Fact] - public void Validate_ReleaseYearPastTheLatestBoundary_Fails() - { - var result = validator.Validate(new CreateAlbumRequest("Title", null, AlbumReleaseYear.Latest + 1, Guid.NewGuid())); + Assert.False(result.IsValid); + } - Assert.False(result.IsValid); - } + [Fact] + public void Validate_EmptyPictureIntentId_Fails() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, null, Guid.Empty)); - [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)); - } + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.PictureIntentId)); } } diff --git a/backend/Tests/Musify.Api.Tests/Validators/CreateUserRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/CreateUserRequestValidatorTests.cs index fffb0bf..36ccb0f 100644 --- a/backend/Tests/Musify.Api.Tests/Validators/CreateUserRequestValidatorTests.cs +++ b/backend/Tests/Musify.Api.Tests/Validators/CreateUserRequestValidatorTests.cs @@ -2,51 +2,50 @@ using Musify.Api.DataTransferObjects.Users; using Musify.Api.Validators.Users; using Xunit; -namespace Musify.Api.Tests.Validators +namespace Musify.Api.Tests.Validators; + +public sealed class CreateUserRequestValidatorTests { - public sealed class CreateUserRequestValidatorTests + private readonly CreateUserRequestValidator validator = new(); + + [Fact] + public void Validate_ValidRequest_Passes() { - private readonly CreateUserRequestValidator validator = new(); + var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", "Jane", "Doe")); - [Fact] - public void Validate_ValidRequest_Passes() - { - var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", "Jane", "Doe")); + Assert.True(result.IsValid); + } - Assert.True(result.IsValid); - } + [Fact] + public void Validate_ZeroId_Fails() + { + var result = validator.Validate(new CreateUserRequest(0, "Jane Doe", null, null)); - [Fact] - public void Validate_ZeroId_Fails() - { - var result = validator.Validate(new CreateUserRequest(0, "Jane Doe", null, null)); + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateUserRequest.Id)); + } - Assert.False(result.IsValid); - Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateUserRequest.Id)); - } + [Fact] + public void Validate_BlankName_Fails() + { + var result = validator.Validate(new CreateUserRequest(1, "", null, null)); - [Fact] - public void Validate_BlankName_Fails() - { - var result = validator.Validate(new CreateUserRequest(1, "", null, null)); + Assert.False(result.IsValid); + } - Assert.False(result.IsValid); - } + [Fact] + public void Validate_NameOverMaxLength_Fails() + { + var result = validator.Validate(new CreateUserRequest(1, new string('a', 49), null, null)); - [Fact] - public void Validate_NameOverMaxLength_Fails() - { - var result = validator.Validate(new CreateUserRequest(1, new string('a', 49), null, null)); + Assert.False(result.IsValid); + } - Assert.False(result.IsValid); - } + [Fact] + public void Validate_OptionalNamesOmitted_Passes() + { + var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", null, null)); - [Fact] - public void Validate_OptionalNamesOmitted_Passes() - { - var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", null, null)); - - Assert.True(result.IsValid); - } + Assert.True(result.IsValid); } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs index 3d78fd9..77caf56 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/AddTrackToAlbumCommandHandlerTests.cs @@ -5,108 +5,107 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Albums +namespace Musify.Application.Tests.Albums; + +public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase { - public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase + private AddTrackToAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_FirstTrack_AddsItAtNumberOne() { - private AddTrackToAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + var track = TestEntities.Track(owner); + await SeedAsync(owner, album, track); - [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); + var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, track.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, track.Id), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + var link = Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal(1, link.TrackNumber); + } - Assert.False(result.IsError); - var link = Assert.Single(await Database.AlbumHasTracks.ToListAsync(TestContext.Current.CancellationToken)); - Assert.Equal(1, link.TrackNumber); - } + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); - [Fact] - public async Task Handle_AlbumMissing_ReturnsNotFound() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, Guid.NewGuid(), track.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, Guid.NewGuid(), track.Id), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_NotOwner_ReturnsUnauthorized() + { + 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); - [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() - { - 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); + var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(stranger.Id, album.Id, track.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(stranger.Id, album.Id, track.Id), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); - } + [Fact] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, album); - [Fact] - public async Task Handle_TrackMissing_ReturnsNotFound() - { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id); - await SeedAsync(owner, album); + var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_TrackOwnedByAnotherUser_ReturnsUnauthorized() + { + 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); - [Fact] - public async Task Handle_TrackOwnedByAnotherUser_ReturnsUnauthorized() - { - 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); + var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, foreignTrack.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, foreignTrack.Id), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); - } + [Fact] + public async Task Handle_AlreadyInAlbum_ReturnsConflict() + { + 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); - [Fact] - public async Task Handle_AlreadyInAlbum_ReturnsConflict() - { - 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); + var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, track.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, track.Id), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + } - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); - } + [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); - [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); + var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, secondTrack.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, secondTrack.Id), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var newLink = await Database.AlbumHasTracks.SingleAsync(l => l.TrackId == secondTrack.Id, TestContext.Current.CancellationToken); - Assert.Equal(4, newLink.TrackNumber); - } + 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 b242565..42b56d7 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/CreateAlbumCommandHandlerTests.cs @@ -6,72 +6,71 @@ using Musify.Application.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Albums +namespace Musify.Application.Tests.Albums; + +public sealed class CreateAlbumCommandHandlerTests : HandlerTestBase { - public sealed class CreateAlbumCommandHandlerTests : HandlerTestBase + private readonly IEventBus eventBus = Substitute.For(); + private readonly IStorageService storageService = Substitute.For(); + + private CreateAlbumCommandHandler CreateHandler() => new( + eventBus, + Database, + new UploadIntentValidator(Database, storageService), + NoOpLogger(), + TestConfigurations.Storage(), + TestConfigurations.Album(), + TestConfigurations.UploadIntent()); + + [Fact] + public async Task Handle_ValidPictureIntent_CreatesAlbumAndPublishesResourcesEvent() { - private readonly IEventBus eventBus = Substitute.For(); - private readonly IStorageService storageService = Substitute.For(); + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); - private CreateAlbumCommandHandler CreateHandler() => new( - eventBus, - Database, - new UploadIntentValidator(Database, storageService), - NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.Album(), - TestConfigurations.UploadIntent()); + var owner = TestEntities.User(); + var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "cover.webp"); + await SeedAsync(owner, intent); - [Fact] - public async Task Handle_ValidPictureIntent_CreatesAlbumAndPublishesResourcesEvent() - { - storageService - .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ObjectMetaData("image/webp", 1024)); + var command = new CreateAlbumCommand(owner.Id, " My Album ", "A description", 2024, intent.Id); - var owner = TestEntities.User(); - var intent = TestEntities.UploadIntent(owner.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.AlbumPicture, objectName: "cover.webp"); - await SeedAsync(owner, intent); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var command = new CreateAlbumCommand(owner.Id, " My Album ", "A description", 2024, intent.Id); + 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.False(result.IsError); - Assert.Equal("My Album", result.Value.Title); - Assert.Equal("A description", result.Value.Description); - Assert.Equal(2024, result.Value.ReleaseYear); - Assert.Equal(owner.Id, result.Value.OwnerUserId); - Assert.Equal(0, result.Value.TrackCount); + [Fact] + public async Task Handle_UserDoesNotExist_ReturnsNotFound() + { + var command = new CreateAlbumCommand(404, "Orphan Album", null, null, Guid.NewGuid()); - 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.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.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var command = new CreateAlbumCommand(owner.Id, "My 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()); - - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - - Assert.True(result.IsError); - } + 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 6d2a371..98c5d27 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/DeleteAlbumCommandHandlerTests.cs @@ -3,45 +3,44 @@ using Musify.Application.Albums; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.Albums +namespace Musify.Application.Tests.Albums; + +public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase { - public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase + private DeleteAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_Owner_RemovesAlbum() { - private DeleteAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger()); + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, album); - [Fact] - public async Task Handle_Owner_RemovesAlbum() - { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id); - await SeedAsync(owner, album); + var result = await CreateHandler().Handle(new DeleteAlbumCommand(owner.Id, album.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new DeleteAlbumCommand(owner.Id, album.Id), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Null(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken)); + } - Assert.False(result.IsError); - Assert.Null(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken)); - } + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new DeleteAlbumCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AlbumMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new DeleteAlbumCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_NotOwner_ReturnsUnauthorizedAndKeepsAlbum() + { + 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_NotOwner_ReturnsUnauthorizedAndKeepsAlbum() - { - 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); - var result = await CreateHandler().Handle(new DeleteAlbumCommand(stranger.Id, album.Id), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); - Assert.NotNull(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken)); - } + Assert.Equal(ErrorType.Unauthorized, 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 bb21b98..798e73f 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumByIdQueryHandlerTests.cs @@ -4,34 +4,33 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Albums +namespace Musify.Application.Tests.Albums; + +public sealed class GetAlbumByIdQueryHandlerTests : HandlerTestBase { - public sealed class GetAlbumByIdQueryHandlerTests : HandlerTestBase + private GetAlbumByIdQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_ExistingAlbum_ReturnsItWithTrackCountAndCovers() { - private GetAlbumByIdQueryHandler CreateHandler() => new(Database); + 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 }); - [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 }); + var result = await CreateHandler().Handle(new GetAlbumByIdQuery(album.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetAlbumByIdQuery(album.Id), 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.False(result.IsError); - Assert.Equal(album.Id, result.Value.Id); - Assert.Equal(1, result.Value.TrackCount); - Assert.Equal([track.Id], result.Value.CoverTrackIds); - } + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetAlbumByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken); - [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); - } + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs index 4a7610a..60a732d 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumCoverQueryHandlerTests.cs @@ -3,36 +3,35 @@ using Musify.Application.Albums; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.Albums +namespace Musify.Application.Tests.Albums; + +public sealed class GetAlbumCoverQueryHandlerTests : HandlerTestBase { - public sealed class GetAlbumCoverQueryHandlerTests : HandlerTestBase + private GetAlbumCoverQueryHandler CreateHandler() => + new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.Album()); + + [Theory] + [InlineData("small", "small.webp")] + [InlineData("large", "large.webp")] + [InlineData("medium", "medium.webp")] + public async Task Handle_ExistingAlbum_ReturnsRequestedSize(string size, string expectedFileName) { - private GetAlbumCoverQueryHandler CreateHandler() => - new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.Album()); + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, album); - [Theory] - [InlineData("small", "small.webp")] - [InlineData("large", "large.webp")] - [InlineData("medium", "medium.webp")] - public async Task Handle_ExistingAlbum_ReturnsRequestedSize(string size, string expectedFileName) - { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id); - await SeedAsync(owner, album); + var result = await CreateHandler().Handle(new GetAlbumCoverQuery(album.Id, size), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetAlbumCoverQuery(album.Id, size), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal("covers-bucket", result.Value.Bucket); + Assert.EndsWith(expectedFileName, result.Value.Key); + } - Assert.False(result.IsError); - Assert.Equal("covers-bucket", result.Value.Bucket); - Assert.EndsWith(expectedFileName, result.Value.Key); - } + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetAlbumCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AlbumMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetAlbumCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + 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 955d7ab..26beb6c 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumTracksQueryHandlerTests.cs @@ -4,36 +4,35 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Albums +namespace Musify.Application.Tests.Albums; + +public sealed class GetAlbumTracksQueryHandlerTests : HandlerTestBase { - public sealed class GetAlbumTracksQueryHandlerTests : HandlerTestBase + private GetAlbumTracksQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_AlbumWithTracks_ReturnsThemOrderedByTrackNumber() { - private GetAlbumTracksQueryHandler CreateHandler() => new(Database); + 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 }); - [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 }); + var result = await CreateHandler().Handle(new GetAlbumTracksQuery(album.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetAlbumTracksQuery(album.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title)); + } - Assert.False(result.IsError); - Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title)); - } + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetAlbumTracksQuery(Guid.NewGuid(), 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.Equal(ErrorType.NotFound, result.FirstError.Type); - } + 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 93be0ed..244a269 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsByUserIdQueryHandlerTests.cs @@ -3,32 +3,31 @@ using Musify.Application.Albums; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.Albums +namespace Musify.Application.Tests.Albums; + +public sealed class GetAlbumsByUserIdQueryHandlerTests : HandlerTestBase { - public sealed class GetAlbumsByUserIdQueryHandlerTests : HandlerTestBase + private GetAlbumsByUserIdQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_UserWithAlbums_ReturnsOnlyTheirAlbums() { - private GetAlbumsByUserIdQueryHandler CreateHandler() => new(Database); + 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")); - [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")); + var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(owner.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(owner.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + var album = Assert.Single(result.Value.Items); + Assert.Equal("Mine", album.Title); + } - Assert.False(result.IsError); - var album = Assert.Single(result.Value.Items); - Assert.Equal("Mine", album.Title); - } + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetAlbumsByUserIdQuery(404, 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.Equal(ErrorType.NotFound, result.FirstError.Type); - } + 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 42f2484..458ab85 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetAlbumsQueryHandlerTests.cs @@ -2,49 +2,48 @@ using Musify.Application.Albums; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.Albums +namespace Musify.Application.Tests.Albums; + +public sealed class GetAlbumsQueryHandlerTests : HandlerTestBase { - public sealed class GetAlbumsQueryHandlerTests : HandlerTestBase + private GetAlbumsQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_NoTitleFilter_ReturnsAllAlbumsPaged() { - private GetAlbumsQueryHandler CreateHandler() => new(Database); + var owner = TestEntities.User(); + await SeedAsync(owner, TestEntities.Album(owner.Id, "Alpha"), TestEntities.Album(owner.Id, "Beta")); - [Fact] - public async Task Handle_NoTitleFilter_ReturnsAllAlbumsPaged() - { - var owner = TestEntities.User(); - await SeedAsync(owner, TestEntities.Album(owner.Id, "Alpha"), TestEntities.Album(owner.Id, "Beta")); + var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(2, result.Value.TotalItemCount); + Assert.All(result.Value.Items, item => Assert.NotNull(item.Album)); + } - Assert.False(result.IsError); - Assert.Equal(2, result.Value.TotalItemCount); - Assert.All(result.Value.Items, item => Assert.NotNull(item.Album)); - } + [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_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: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: "rock", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + var item = Assert.Single(result.Value.Items); + Assert.Equal("Rock Classics", item.Album.Title); + } - Assert.False(result.IsError); - var item = Assert.Single(result.Value.Items); - Assert.Equal("Rock Classics", item.Album.Title); - } + [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")); - [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); - var result = await CreateHandler().Handle(new GetAlbumsQuery(Title: null, PageNumber: 1, PageSize: 2), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(2, result.Value.Items.Count); - Assert.True(result.Value.HasNextPage); - } + Assert.False(result.IsError); + Assert.Equal(2, result.Value.Items.Count); + Assert.True(result.Value.HasNextPage); } } diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs index cff1661..080d994 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs @@ -3,79 +3,78 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Albums +namespace Musify.Application.Tests.Albums; + +public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase { - public sealed class GetRecentlyListenedAlbumsQueryHandlerTests : HandlerTestBase + private GetRecentlyListenedAlbumsQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_ListenedTracks_ReturnsTheirAlbumsMostRecentFirst() { - private GetRecentlyListenedAlbumsQueryHandler CreateHandler() => new(Database); + 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_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))); + var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(["Newer", "Older"], result.Value.Select(album => album.Title)); + } - Assert.False(result.IsError); - Assert.Equal(["Newer", "Older"], result.Value.Select(album => album.Title)); - } + [Fact] + public async Task Handle_NoListeningHistory_ReturnsEmptyList() + { + var owner = TestEntities.User(); + await SeedAsync(owner); - [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); - var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Empty(result.Value); + } - Assert.False(result.IsError); - Assert.Empty(result.Value); - } + [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_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: 2), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 2), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(2, result.Value.Count); + } - Assert.False(result.IsError); - Assert.Equal(2, result.Value.Count); - } + [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)); - [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: 10), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); - - Assert.Equal(["Counted album"], result.Value.Select(album => album.Title)); - } + Assert.Equal(["Counted album"], result.Value.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 62516f2..c792fe9 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/RemoveTrackFromAlbumCommandHandlerTests.cs @@ -5,69 +5,68 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Albums +namespace Musify.Application.Tests.Albums; + +public sealed class RemoveTrackFromAlbumCommandHandlerTests : HandlerTestBase { - public sealed class RemoveTrackFromAlbumCommandHandlerTests : HandlerTestBase + private RemoveTrackFromAlbumCommandHandler CreateHandler() => + new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_TrackInAlbum_RemovesItAndRenumbersTheRest() { - private RemoveTrackFromAlbumCommandHandler CreateHandler() => - new(Database, NoOpLogger()); + 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 }); - [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 }); + var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, second.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, second.Id), 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.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)); - } + [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_AlbumMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_NotOwner_ReturnsUnauthorized() + { + 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_NotOwner_ReturnsUnauthorized() - { - 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 RemoveTrackFromAlbumCommand(stranger.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(stranger.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); - } + [Fact] + public async Task Handle_TrackNotInAlbum_ReturnsNotFound() + { + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, album); - [Fact] - public async Task Handle_TrackNotInAlbum_ReturnsNotFound() - { - var owner = TestEntities.User(); - var album = TestEntities.Album(owner.Id); - await SeedAsync(owner, album); + var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(owner.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + 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 4c0a83c..1a17d19 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/RequestAlbumPictureUploadCommandHandlerTests.cs @@ -8,68 +8,67 @@ using Musify.Application.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Albums +namespace Musify.Application.Tests.Albums; + +public sealed class RequestAlbumPictureUploadCommandHandlerTests : HandlerTestBase { - public sealed class RequestAlbumPictureUploadCommandHandlerTests : HandlerTestBase + private readonly IStorageService storageService = Substitute.For(); + + private RequestAlbumPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) { - private readonly IStorageService storageService = Substitute.For(); + storageService + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://storage.musify.test/presigned-upload"); - private RequestAlbumPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) - { - storageService - .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns("https://storage.musify.test/presigned-upload"); + return new RequestAlbumPictureUploadCommandHandler( + Database, + new UploadIntentValidator(Database, storageService), + storageService, + NoOpLogger(), + TestConfigurations.Storage(), + TestConfigurations.Album(), + uploadIntentConfig ?? TestConfigurations.UploadIntent()); + } - return new RequestAlbumPictureUploadCommandHandler( - Database, - new UploadIntentValidator(Database, storageService), - storageService, - NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.Album(), - uploadIntentConfig ?? TestConfigurations.UploadIntent()); - } + [Fact] + public async Task Handle_UserExists_IssuesUploadUrlAndCreatesIntent() + { + var user = TestEntities.User(); + await SeedAsync(user); - [Fact] - public async Task Handle_UserExists_IssuesUploadUrlAndCreatesIntent() - { - var user = TestEntities.User(); - await SeedAsync(user); + var command = new RequestAlbumPictureUploadCommand(user.Id, "webp", "image/webp"); - var command = new RequestAlbumPictureUploadCommand(user.Id, "webp", "image/webp"); + 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.UploadUrl); + Assert.Single(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); + } - Assert.False(result.IsError); - Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.UploadUrl); - Assert.Single(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); - } + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var command = new RequestAlbumPictureUploadCommand(404, "webp", "image/webp"); - [Fact] - public async Task Handle_UserMissing_ReturnsNotFound() - { - var command = new RequestAlbumPictureUploadCommand(404, "webp", "image/webp"); + 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_UserOverByteQuota_ReturnsValidationErrorAndCreatesNoIntent() + { + var config = TestConfigurations.UploadIntent(); + config.MaxActiveUploadBytesPerUser = 100; - [Fact] - public async Task Handle_UserOverByteQuota_ReturnsValidationErrorAndCreatesNoIntent() - { - var config = TestConfigurations.UploadIntent(); - config.MaxActiveUploadBytesPerUser = 100; + var user = TestEntities.User(); + await SeedAsync(user); - var user = TestEntities.User(); - await SeedAsync(user); + var command = new RequestAlbumPictureUploadCommand(user.Id, "webp", "image/webp", ExpectedSizeBytes: 1_000_000); - var command = new RequestAlbumPictureUploadCommand(user.Id, "webp", "image/webp", ExpectedSizeBytes: 1_000_000); + 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.Empty(await Database.UploadIntents.ToListAsync(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 3d304d0..bd46886 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/UpdateAlbumCommandHandlerTests.cs @@ -6,89 +6,88 @@ using Musify.Application.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Albums +namespace Musify.Application.Tests.Albums; + +public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase { - public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase + private readonly IEventBus eventBus = Substitute.For(); + private readonly IStorageService storageService = Substitute.For(); + + private UpdateAlbumCommandHandler CreateHandler() => new( + eventBus, + Database, + new UploadIntentValidator(Database, storageService), + NoOpLogger(), + TestConfigurations.Storage(), + TestConfigurations.Album(), + TestConfigurations.UploadIntent()); + + [Fact] + public async Task Handle_Owner_UpdatesTitleDescriptionAndReleaseYear() { - private readonly IEventBus eventBus = Substitute.For(); - private readonly IStorageService storageService = Substitute.For(); + var owner = TestEntities.User(); + var album = TestEntities.Album(owner.Id, title: "Old Title", description: "Old", releaseYear: 2020); + await SeedAsync(owner, album); - private UpdateAlbumCommandHandler CreateHandler() => new( - eventBus, - Database, - new UploadIntentValidator(Database, storageService), - NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.Album(), - TestConfigurations.UploadIntent()); + var command = new UpdateAlbumCommand(owner.Id, album.Id, " New Title ", "New description", 2025, null); - [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); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var command = new UpdateAlbumCommand(owner.Id, album.Id, " New Title ", "New description", 2025, null); + Assert.False(result.IsError); + Assert.Equal("New Title", result.Value.Title); + Assert.Equal("New description", result.Value.Description); + Assert.Equal(2025, result.Value.ReleaseYear); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("NEW TITLE", stored.NormalizedTitle); + } - Assert.False(result.IsError); - Assert.Equal("New Title", result.Value.Title); - Assert.Equal("New description", result.Value.Description); - Assert.Equal(2025, result.Value.ReleaseYear); + [Fact] + public async Task Handle_ValidPictureIntent_UpdatesPictureAndPublishesSourceEvent() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); - var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal("NEW TITLE", stored.NormalizedTitle); - } + 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); - [Fact] - public async Task Handle_ValidPictureIntent_UpdatesPictureAndPublishesSourceEvent() - { - storageService - .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ObjectMetaData("image/webp", 1024)); + var command = new UpdateAlbumCommand(owner.Id, album.Id, "Old Title", null, null, intent.Id); - 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 result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var command = new UpdateAlbumCommand(owner.Id, album.Id, "Old Title", null, null, intent.Id); + 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 result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_AlbumMissing_ReturnsNotFound() + { + var command = new UpdateAlbumCommand(1, Guid.NewGuid(), "Title", null, null, null); - 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 result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_AlbumMissing_ReturnsNotFound() - { - var command = new UpdateAlbumCommand(1, Guid.NewGuid(), "Title", null, null, null); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NotOwner_ReturnsUnauthorized() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var album = TestEntities.Album(owner.Id); + await SeedAsync(owner, stranger, album); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var command = new UpdateAlbumCommand(stranger.Id, album.Id, "Hijacked", null, null, null); - [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() - { - 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); - - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); - } + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); } } diff --git a/backend/Tests/Musify.Application.Tests/Configuration/GetPlaybackPublicConfigTests.cs b/backend/Tests/Musify.Application.Tests/Configuration/GetPlaybackPublicConfigTests.cs index fb11260..bc45a0b 100644 --- a/backend/Tests/Musify.Application.Tests/Configuration/GetPlaybackPublicConfigTests.cs +++ b/backend/Tests/Musify.Application.Tests/Configuration/GetPlaybackPublicConfigTests.cs @@ -2,30 +2,29 @@ using Musify.Application.Configuration; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.Configuration +namespace Musify.Application.Tests.Configuration; + +public sealed class GetPlaybackPublicConfigTests { - public sealed class GetPlaybackPublicConfigTests + [Fact] + public async Task Handle_ReflectsPlaybackConfiguration() { - [Fact] - public async Task Handle_ReflectsPlaybackConfiguration() - { - var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); - var handler = new GetPlaybackPublicConfigQueryHandler(playback); + 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/Genres/GetAvailableGenresQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs index 0f8d698..bba8f87 100644 --- a/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs @@ -2,31 +2,30 @@ using Musify.Application.Genres; using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Application.Tests.Genres +namespace Musify.Application.Tests.Genres; + +public sealed class GetAvailableGenresQueryHandlerTests { - public sealed class GetAvailableGenresQueryHandlerTests + private static async Task> RunAsync() => + await new GetAvailableGenresQueryHandler().Handle(new GetAvailableGenresQuery(), TestContext.Current.CancellationToken); + + [Fact] + public async Task Handle_ReturnsEveryGenre() { - private static async Task> RunAsync() => - await new GetAvailableGenresQueryHandler().Handle(new GetAvailableGenresQuery(), TestContext.Current.CancellationToken); + var result = await RunAsync(); - [Fact] - public async Task Handle_ReturnsEveryGenre() - { - var result = await RunAsync(); + Assert.Equal(Enum.GetValues(), result.Select(r => r.Genre)); + } - Assert.Equal(Enum.GetValues(), result.Select(r => r.Genre)); - } + [Fact] + public async Task Handle_ListsIncompatibleGenresSymmetrically() + { + var result = await RunAsync(); - [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); - } + 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 cb4d6e9..c2e4393 100644 --- a/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs @@ -4,66 +4,65 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Application.Tests.Genres +namespace Musify.Application.Tests.Genres; + +public sealed class GetGenresQueryHandlerTests : HandlerTestBase { - public sealed class GetGenresQueryHandlerTests : HandlerTestBase + private GetGenresQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_NoTracks_ReturnsEmpty() { - private GetGenresQueryHandler CreateHandler() => new(Database); + var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_NoTracks_ReturnsEmpty() - { - var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); + Assert.Empty(result); + } - 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, + new UserHasTrack { UserId = owner.Id, TrackId = rockA.Id }, + new UserHasTrack { UserId = owner.Id, TrackId = rockB.Id }, + new UserHasTrack { UserId = owner.Id, TrackId = jazz.Id }); - [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, - new UserHasTrack { UserId = owner.Id, TrackId = rockA.Id }, - new UserHasTrack { UserId = owner.Id, TrackId = rockB.Id }, - new UserHasTrack { UserId = owner.Id, TrackId = jazz.Id }); + var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); - 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.Equal( - [(Genre.Rock, 2), (Genre.Jazz, 1), (Genre.Indie, 1)], - result.Select(r => (r.Genre, r.TrackCount))); - } + [Fact] + public async Task Handle_TrackInSeveralLibraries_IsCountedOnce() + { + var owner = TestEntities.User(); + var other = TestEntities.User(id: 2, name: "other-user"); + var track = TestEntities.Track(owner, tags: [Genre.Pop]); + await SeedAsync( + owner, other, track, + new UserHasTrack { UserId = owner.Id, TrackId = track.Id }, + new UserHasTrack { UserId = other.Id, TrackId = track.Id }); - [Fact] - public async Task Handle_TrackInSeveralLibraries_IsCountedOnce() - { - var owner = TestEntities.User(); - var other = TestEntities.User(id: 2, name: "other-user"); - var track = TestEntities.Track(owner, tags: [Genre.Pop]); - await SeedAsync( - owner, other, track, - new UserHasTrack { UserId = owner.Id, TrackId = track.Id }, - new UserHasTrack { UserId = other.Id, TrackId = track.Id }); + var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); + Assert.Equal(1, Assert.Single(result).TrackCount); + } - Assert.Equal(1, Assert.Single(result).TrackCount); - } + [Fact] + public async Task Handle_TrackNotInAnyLibrary_IsIgnored() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner, tags: [Genre.Metal]); + await SeedAsync(owner, track); - [Fact] - public async Task Handle_TrackNotInAnyLibrary_IsIgnored() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner, tags: [Genre.Metal]); - await SeedAsync(owner, track); + var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); - - Assert.Empty(result); - } + Assert.Empty(result); } } diff --git a/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs index 158ee08..ae6a239 100644 --- a/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Likes/GetLikedTracksQueryHandlerTests.cs @@ -3,53 +3,52 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Likes +namespace Musify.Application.Tests.Likes; + +public sealed class GetLikedTracksQueryHandlerTests : HandlerTestBase { - public sealed class GetLikedTracksQueryHandlerTests : HandlerTestBase + private GetLikedTracksQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_MultipleLikes_ReturnsMostRecentFirst() { - private GetLikedTracksQueryHandler CreateHandler() => new(Database); + 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_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 }); + var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(["Newer like", "Older like"], result.Value.Items.Select(track => track.Title)); + } - Assert.False(result.IsError); - Assert.Equal(["Newer like", "Older like"], result.Value.Items.Select(track => track.Title)); - } + [Fact] + public async Task Handle_NoLikes_ReturnsEmpty() + { + var owner = TestEntities.User(); + await SeedAsync(owner); - [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); - var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Empty(result.Value.Items); + } - Assert.False(result.IsError); - Assert.Empty(result.Value.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 }); - [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); - var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Empty(result.Value.Items); - } + Assert.False(result.IsError); + Assert.Empty(result.Value.Items); } } diff --git a/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs index f03b174..1234a57 100644 --- a/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Likes/ToggleTrackLikeCommandHandlerTests.cs @@ -4,53 +4,52 @@ using Musify.Application.Likes; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.Likes +namespace Musify.Application.Tests.Likes; + +public sealed class ToggleTrackLikeCommandHandlerTests : HandlerTestBase { - public sealed class ToggleTrackLikeCommandHandlerTests : HandlerTestBase + private ToggleTrackLikeCommandHandler CreateHandler() => + new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_TrackNotLiked_LikesIt() { - private ToggleTrackLikeCommandHandler CreateHandler() => - new(Database, NoOpLogger()); + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); - [Fact] - public async Task Handle_TrackNotLiked_LikesIt() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + var result = await CreateHandler().Handle( + new ToggleTrackLikeCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle( - new ToggleTrackLikeCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.True(result.Value); + Assert.Single(await Database.TrackLikes.ToListAsync(TestContext.Current.CancellationToken)); + } - Assert.False(result.IsError); - Assert.True(result.Value); - Assert.Single(await Database.TrackLikes.ToListAsync(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 }); - [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 }); + var result = await CreateHandler().Handle( + new ToggleTrackLikeCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle( - new ToggleTrackLikeCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.False(result.Value); + Assert.Empty(await Database.TrackLikes.ToListAsync(TestContext.Current.CancellationToken)); + } - Assert.False(result.IsError); - Assert.False(result.Value); - Assert.Empty(await Database.TrackLikes.ToListAsync(TestContext.Current.CancellationToken)); - } + [Fact] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var owner = TestEntities.User(); + await SeedAsync(owner); - [Fact] - public async Task Handle_TrackMissing_ReturnsNotFound() - { - var owner = TestEntities.User(); - await SeedAsync(owner); + var result = await CreateHandler().Handle( + new ToggleTrackLikeCommand(owner.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle( - new ToggleTrackLikeCommand(owner.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + 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 70bd2d1..ad45bd3 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs @@ -4,89 +4,88 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Mixes +namespace Musify.Application.Tests.Mixes; + +public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase { - public sealed class GenerateMixesForUserCommandHandlerTests : HandlerTestBase + private GenerateMixesForUserCommandHandler CreateHandler() => new( + Database, TestConfigurations.Mix(), NoOpLogger()); + + [Fact] + public async Task Handle_NoTracks_DoesNotCreateAnyMix() { - private GenerateMixesForUserCommandHandler CreateHandler() => new( - Database, TestConfigurations.Mix(), NoOpLogger()); + var user = TestEntities.User(); + await SeedAsync(user); - [Fact] - public async Task Handle_NoTracks_DoesNotCreateAnyMix() - { - var user = TestEntities.User(); - await SeedAsync(user); + var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken)); + } - Assert.False(result.IsError); - Assert.Empty(await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken)); - } + [Fact] + public async Task Handle_AllTracksAlreadyListenedTo_CreatesOnlyTheDailyMix() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user); + await SeedAsync( + user, track, + new UserHasTrack { UserId = user.Id, TrackId = track.Id }, + TestEntities.ListeningHistory(user.Id, track.Id)); - [Fact] - public async Task Handle_AllTracksAlreadyListenedTo_CreatesOnlyTheDailyMix() - { - var user = TestEntities.User(); - var track = TestEntities.Track(user); - await SeedAsync( - user, track, - new UserHasTrack { UserId = user.Id, TrackId = track.Id }, - TestEntities.ListeningHistory(user.Id, track.Id)); + var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); + Assert.Single(mixes); + Assert.Equal("Tu mezcla diaria", mixes[0].Title); + } - Assert.False(result.IsError); - var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); - Assert.Single(mixes); - Assert.Equal("Tu mezcla diaria", mixes[0].Title); - } + [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"); - [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"); + var staleMix = TestEntities.Mix(user.Id, "Stale Mix"); + var staleItem = TestEntities.MixItem(staleMix.Id, listenedTrack.Id); - var staleMix = TestEntities.Mix(user.Id, "Stale Mix"); - var staleItem = TestEntities.MixItem(staleMix.Id, listenedTrack.Id); + await SeedAsync( + user, listenedTrack, unheardTrack, staleMix, staleItem, + new UserHasTrack { UserId = user.Id, TrackId = listenedTrack.Id }, + new UserHasTrack { UserId = user.Id, TrackId = unheardTrack.Id }, + TestEntities.ListeningHistory(user.Id, listenedTrack.Id)); - await SeedAsync( - user, listenedTrack, unheardTrack, staleMix, staleItem, - new UserHasTrack { UserId = user.Id, TrackId = listenedTrack.Id }, - new UserHasTrack { UserId = user.Id, TrackId = unheardTrack.Id }, - TestEntities.ListeningHistory(user.Id, listenedTrack.Id)); + var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); + Assert.DoesNotContain(mixes, mix => mix.Title == "Stale Mix"); + Assert.Contains(mixes, mix => mix.Title == "Descubrimiento"); + Assert.Contains(mixes, mix => mix.Title == "Tu mezcla diaria"); + Assert.Empty(await Database.MixItems.Where(item => item.MixId == staleMix.Id).ToListAsync(TestContext.Current.CancellationToken)); - Assert.False(result.IsError); - var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); - Assert.DoesNotContain(mixes, mix => mix.Title == "Stale Mix"); - Assert.Contains(mixes, mix => mix.Title == "Descubrimiento"); - Assert.Contains(mixes, mix => mix.Title == "Tu mezcla diaria"); - Assert.Empty(await Database.MixItems.Where(item => item.MixId == staleMix.Id).ToListAsync(TestContext.Current.CancellationToken)); + var discoveryMix = mixes.Single(mix => mix.Title == "Descubrimiento"); + 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)); + } - var discoveryMix = mixes.Single(mix => mix.Title == "Descubrimiento"); - 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)); - } + [Fact] + public async Task Handle_TrackWithOnlyUncountedListens_IsTreatedAsUnheard() + { + var user = TestEntities.User(); + var skipped = TestEntities.Track(user, "Only skipped"); + await SeedAsync( + user, skipped, + new UserHasTrack { UserId = user.Id, TrackId = skipped.Id }, + TestEntities.ListeningHistory(user.Id, skipped.Id, playedSeconds: 3, isCounted: false)); - [Fact] - public async Task Handle_TrackWithOnlyUncountedListens_IsTreatedAsUnheard() - { - var user = TestEntities.User(); - var skipped = TestEntities.Track(user, "Only skipped"); - await SeedAsync( - user, skipped, - new UserHasTrack { UserId = user.Id, TrackId = skipped.Id }, - TestEntities.ListeningHistory(user.Id, skipped.Id, playedSeconds: 3, isCounted: false)); + await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); - await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); - - var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); - Assert.Contains(mixes, mix => mix.Title == "Descubrimiento"); - } + var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); + Assert.Contains(mixes, mix => mix.Title == "Descubrimiento"); } } diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs index 97ba344..e7a68b8 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GetMixByIdQueryHandlerTests.cs @@ -3,48 +3,47 @@ using Musify.Application.Mixes; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.Mixes +namespace Musify.Application.Tests.Mixes; + +public sealed class GetMixByIdQueryHandlerTests : HandlerTestBase { - public sealed class GetMixByIdQueryHandlerTests : HandlerTestBase + private GetMixByIdQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_OwnedMix_ReturnsItWithOrderedItems() { - private GetMixByIdQueryHandler CreateHandler() => new(Database); + var owner = TestEntities.User(); + var firstTrack = TestEntities.Track(owner, "First"); + var secondTrack = TestEntities.Track(owner, "Second"); + var mix = TestEntities.Mix(owner.Id, "Discovery"); + 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); - [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, "Discovery"); - 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); + var result = await CreateHandler().Handle(new GetMixByIdQuery(owner.Id, mix.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetMixByIdQuery(owner.Id, mix.Id), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal("Discovery", result.Value.Title); + Assert.Equal(["First", "Second"], result.Value.Items.Select(item => item.Title)); + } - Assert.False(result.IsError); - Assert.Equal("Discovery", result.Value.Title); - Assert.Equal(["First", "Second"], result.Value.Items.Select(item => item.Title)); - } + [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_MixMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetMixByIdQuery(1, Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_MixBelongsToAnotherUser_ReturnsNotFound() + { + var owner = TestEntities.User(1, "owner"); + var mix = TestEntities.Mix(owner.Id); + await SeedAsync(owner, mix); - [Fact] - public async Task Handle_MixBelongsToAnotherUser_ReturnsNotFound() - { - var owner = TestEntities.User(1, "owner"); - var mix = TestEntities.Mix(owner.Id); - await SeedAsync(owner, mix); + var result = await CreateHandler().Handle(new GetMixByIdQuery(2, mix.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetMixByIdQuery(2, mix.Id), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + 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 d5ec0d9..9533607 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GetMixesByUserIdQueryHandlerTests.cs @@ -2,38 +2,37 @@ using Musify.Application.Mixes; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.Mixes +namespace Musify.Application.Tests.Mixes; + +public sealed class GetMixesByUserIdQueryHandlerTests : HandlerTestBase { - public sealed class GetMixesByUserIdQueryHandlerTests : HandlerTestBase + private GetMixesByUserIdQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_ReturnsOnlyThatUsersMixesOrderedByPosition() { - private GetMixesByUserIdQueryHandler CreateHandler() => new(Database); + var owner = TestEntities.User(1, "owner"); + var other = TestEntities.User(2, "other"); + var second = TestEntities.Mix(owner.Id, "Second", position: 1); + var first = TestEntities.Mix(owner.Id, "First", position: 0); + var theirs = TestEntities.Mix(other.Id, "Theirs"); + await SeedAsync(owner, other, second, first, theirs); - [Fact] - public async Task Handle_ReturnsOnlyThatUsersMixesOrderedByPosition() - { - var owner = TestEntities.User(1, "owner"); - var other = TestEntities.User(2, "other"); - var second = TestEntities.Mix(owner.Id, "Second", position: 1); - var first = TestEntities.Mix(owner.Id, "First", position: 0); - var theirs = TestEntities.Mix(other.Id, "Theirs"); - await SeedAsync(owner, other, second, first, theirs); + var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(["First", "Second"], result.Value.Select(mix => mix.Title)); + } - Assert.False(result.IsError); - Assert.Equal(["First", "Second"], result.Value.Select(mix => mix.Title)); - } + [Fact] + public async Task Handle_NoMixes_ReturnsEmptyList() + { + var owner = TestEntities.User(); + await SeedAsync(owner); - [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); - var result = await CreateHandler().Handle(new GetMixesByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Empty(result.Value); - } + Assert.False(result.IsError); + Assert.Empty(result.Value); } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs index 2e1fad1..bf5322f 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/AddTrackToPlayListCommandHandlerTests.cs @@ -5,89 +5,88 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.PlayLists +namespace Musify.Application.Tests.PlayLists; + +public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase { - public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase + private AddTrackToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_FirstTrack_AddsItAtPositionZero() { - private AddTrackToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger()); + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + var track = TestEntities.Track(owner); + await SeedAsync(owner, playList, track); - [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); + var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + var link = Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal(0, link.Position); + } - Assert.False(result.IsError); - var link = Assert.Single(await Database.PlayListHasTracks.ToListAsync(TestContext.Current.CancellationToken)); - Assert.Equal(0, link.Position); - } + [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 }); - [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 }); + var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, newTrack.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, newTrack.Id), 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.False(result.IsError); - var newLink = await Database.PlayListHasTracks.SingleAsync(link => link.TrackId == newTrack.Id, TestContext.Current.CancellationToken); - Assert.Equal(1, newLink.Position); - } + [Fact] + public async Task Handle_PlayListMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PlayListMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_NotOwner_ReturnsUnauthorized() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, stranger, playList); - [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() - { - var owner = TestEntities.User(1, "owner"); - var stranger = TestEntities.User(2, "stranger"); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, stranger, playList); + var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); - } + [Fact] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, playList); - [Fact] - public async Task Handle_TrackMissing_ReturnsNotFound() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, playList); + var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_TrackAlreadyInPlayList_ReturnsConflict() + { + 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 }); - [Fact] - public async Task Handle_TrackAlreadyInPlayList_ReturnsConflict() - { - 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 }); + var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); - } + Assert.Equal(ErrorType.Conflict, result.FirstError.Type); } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs index 697a4de..4a9ea17 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/CreatePlayListCommandHandlerTests.cs @@ -6,109 +6,108 @@ using Musify.Application.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.PlayLists +namespace Musify.Application.Tests.PlayLists; + +public sealed class CreatePlayListCommandHandlerTests : HandlerTestBase { - public sealed class CreatePlayListCommandHandlerTests : HandlerTestBase + private readonly IEventBus eventBus = Substitute.For(); + private readonly IStorageService storageService = Substitute.For(); + + private CreatePlayListCommandHandler CreateHandler() => new( + eventBus, + Database, + new UploadIntentValidator(Database, storageService), + NoOpLogger(), + TestConfigurations.Storage(), + TestConfigurations.PlayList(), + TestConfigurations.UploadIntent()); + + [Fact] + public async Task Handle_NoPictureIntent_CreatesPlayListWithoutPictures() { - private readonly IEventBus eventBus = Substitute.For(); - private readonly IStorageService storageService = Substitute.For(); + var user = TestEntities.User(); + await SeedAsync(user); - private CreatePlayListCommandHandler CreateHandler() => new( - eventBus, - Database, - new UploadIntentValidator(Database, storageService), - NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.PlayList(), - TestConfigurations.UploadIntent()); + var command = new CreatePlayListCommand(user.Id, "My Playlist", "A description", PictureIntentId: null); - [Fact] - public async Task Handle_NoPictureIntent_CreatesPlayListWithoutPictures() - { - var user = TestEntities.User(); - await SeedAsync(user); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var command = new CreatePlayListCommand(user.Id, "My Playlist", "A description", PictureIntentId: null); + 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 result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_WithValidPictureIntent_PublishesResourcesEvent() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); - 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 user = TestEntities.User(); + var intent = TestEntities.UploadIntent(user.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.PlayListPicture, objectName: "cover.webp"); + await SeedAsync(user, intent); - [Fact] - public async Task Handle_WithValidPictureIntent_PublishesResourcesEvent() - { - storageService - .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ObjectMetaData("image/webp", 1024)); + var command = new CreatePlayListCommand(user.Id, "My Playlist", null, intent.Id); - var user = TestEntities.User(); - var intent = TestEntities.UploadIntent(user.Id, Musify.Domain.ValueObjects.UploadIntentPurpose.PlayListPicture, objectName: "cover.webp"); - await SeedAsync(user, intent); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var command = new CreatePlayListCommand(user.Id, "My Playlist", null, intent.Id); + 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); - 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, PictureIntentId: null); - [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); + 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.Private, 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_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.Public, result.Value.Visibility); + } - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var command = new CreatePlayListCommand(404, "Orphan Playlist", 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_UserMissing_ReturnsNotFound() - { - var command = new CreatePlayListCommand(404, "Orphan Playlist", null, null); + 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.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var command = new CreatePlayListCommand(user.Id, "My Playlist", null, Guid.NewGuid()); - [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()); - - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - - Assert.True(result.IsError); - } + 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 2c637a8..7965b4e 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/DeletePlayListCommandHandlerTests.cs @@ -6,49 +6,48 @@ using Musify.Domain.ValueObjects; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.PlayLists +namespace Musify.Application.Tests.PlayLists; + +public sealed class DeletePlayListCommandHandlerTests : HandlerTestBase { - public sealed class DeletePlayListCommandHandlerTests : HandlerTestBase + private readonly IEventBus eventBus = Substitute.For(); + + private DeletePlayListCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); + + [Fact] + public async Task Handle_Owner_MarksPlayListAsRemoving() { - private readonly IEventBus eventBus = Substitute.For(); + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, playList); - private DeletePlayListCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); + var result = await CreateHandler().Handle(new DeletePlayListCommand(owner.Id, playList.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_Owner_MarksPlayListAsRemoving() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, playList); + 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()); + } - var result = await CreateHandler().Handle(new DeletePlayListCommand(owner.Id, playList.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_PlayListMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new DeletePlayListCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken); - 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()); - } + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - [Fact] - public async Task Handle_PlayListMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new DeletePlayListCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NotOwner_ReturnsUnauthorized() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, stranger, playList); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new DeletePlayListCommand(stranger.Id, playList.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() - { - var owner = TestEntities.User(1, "owner"); - var stranger = TestEntities.User(2, "stranger"); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, stranger, playList); - - var result = await CreateHandler().Handle(new DeletePlayListCommand(stranger.Id, playList.Id), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); - } + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs index 0610a17..e393c0d 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListByIdQueryHandlerTests.cs @@ -4,82 +4,81 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.PlayLists +namespace Musify.Application.Tests.PlayLists; + +public sealed class GetPlayListByIdQueryHandlerTests : HandlerTestBase { - public sealed class GetPlayListByIdQueryHandlerTests : HandlerTestBase + private GetPlayListByIdQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_ExistingPlayList_ReturnsItWithCoverTrackIds() { - private GetPlayListByIdQueryHandler CreateHandler() => new(Database); + 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 }); - [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 }); + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(playList.Name, result.Value.Name); + Assert.Equal([track.Id], result.Value.CoverTrackIds); + } - Assert.False(result.IsError); - Assert.Equal(playList.Name, result.Value.Name); - Assert.Equal([track.Id], result.Value.CoverTrackIds); - } + [Fact] + public async Task Handle_PlayListMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PlayListMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetPlayListByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [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); - [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); + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, owner.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, owner.Id), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + } - Assert.False(result.IsError); - } + [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); - [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); + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, stranger.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, stranger.Id), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [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); - [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); + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [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); - [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); + var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - } + Assert.False(result.IsError); } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs index 6c7dbbe..6a7e02f 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListCoverQueryHandlerTests.cs @@ -3,49 +3,48 @@ using Musify.Application.PlayLists; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.PlayLists +namespace Musify.Application.Tests.PlayLists; + +public sealed class GetPlayListCoverQueryHandlerTests : HandlerTestBase { - public sealed class GetPlayListCoverQueryHandlerTests : HandlerTestBase + private GetPlayListCoverQueryHandler CreateHandler() => + new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.PlayList()); + + [Theory] + [InlineData("small", "small.webp")] + [InlineData("large", "large.webp")] + [InlineData("medium", "medium.webp")] + public async Task Handle_ExistingPlayList_ReturnsRequestedSize(string size, string expectedFileName) { - private GetPlayListCoverQueryHandler CreateHandler() => - new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.PlayList()); + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, playList); - [Theory] - [InlineData("small", "small.webp")] - [InlineData("large", "large.webp")] - [InlineData("medium", "medium.webp")] - public async Task Handle_ExistingPlayList_ReturnsRequestedSize(string size, string expectedFileName) - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, playList); + var result = await CreateHandler().Handle(new GetPlayListCoverQuery(playList.Id, size), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetPlayListCoverQuery(playList.Id, size), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal("covers-bucket", result.Value.Bucket); + Assert.EndsWith(expectedFileName, result.Value.Key); + } - Assert.False(result.IsError); - Assert.Equal("covers-bucket", result.Value.Bucket); - Assert.EndsWith(expectedFileName, result.Value.Key); - } + [Fact] + public async Task Handle_PlayListMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetPlayListCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PlayListMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetPlayListCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_PlayListWithoutPictures_ReturnsNotFound() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + playList.Pictures = null; + await SeedAsync(owner, playList); - [Fact] - public async Task Handle_PlayListWithoutPictures_ReturnsNotFound() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - playList.Pictures = null; - await SeedAsync(owner, playList); + var result = await CreateHandler().Handle(new GetPlayListCoverQuery(playList.Id, "small"), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetPlayListCoverQuery(playList.Id, "small"), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs index bedf50f..5f181b3 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs @@ -4,36 +4,35 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.PlayLists +namespace Musify.Application.Tests.PlayLists; + +public sealed class GetPlayListTracksQueryHandlerTests : HandlerTestBase { - public sealed class GetPlayListTracksQueryHandlerTests : HandlerTestBase + private GetPlayListTracksQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_TracksOrderedByPosition_ReturnsThemInOrder() { - private GetPlayListTracksQueryHandler CreateHandler() => new(Database); + 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 }); - [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 }); + var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title)); + } - Assert.False(result.IsError); - Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title)); - } + [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_PlayListMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetPlayListTracksQuery(Guid.NewGuid(), PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs index 4e051e5..9623518 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs @@ -3,57 +3,56 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Application.Tests.PlayLists +namespace Musify.Application.Tests.PlayLists; + +public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase { - public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase + private GetPlayListsByUserIdQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_ReturnsOnlyActivePlayListsForThatUser() { - private GetPlayListsByUserIdQueryHandler CreateHandler() => new(Database); + 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_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")); + var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + var playList = Assert.Single(result.Value.Items); + Assert.Equal("Mine", playList.Name); + } - Assert.False(result.IsError); - var playList = Assert.Single(result.Value.Items); - Assert.Equal("Mine", playList.Name); - } + [Fact] + public async Task Handle_OnlyPublic_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_OnlyPublic_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, OnlyPublic: true), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, OnlyPublic: true), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + var playList = Assert.Single(result.Value.Items); + Assert.Equal("Public one", playList.Name); + } - Assert.False(result.IsError); - var playList = Assert.Single(result.Value.Items); - Assert.Equal("Public one", 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")); - [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), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: "road", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var playList = Assert.Single(result.Value.Items); - Assert.Equal("Road Trip", playList.Name); - } + Assert.False(result.IsError); + var playList = Assert.Single(result.Value.Items); + Assert.Equal("Road Trip", playList.Name); } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs index ba64fa9..3c8b364 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs @@ -2,31 +2,30 @@ using Musify.Application.PlayLists; using Musify.Application.Tests.TestSupport; using Xunit; -namespace Musify.Application.Tests.PlayLists +namespace Musify.Application.Tests.PlayLists; + +public sealed class GetPlayListsQueryHandlerTests : HandlerTestBase { - public sealed class GetPlayListsQueryHandlerTests : HandlerTestBase + private GetPlayListsQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_ReturnsEveryPlayListPaged() { - private GetPlayListsQueryHandler CreateHandler() => new(Database); + var owner = TestEntities.User(); + await SeedAsync(owner, TestEntities.PlayList(owner.Id, "One"), TestEntities.PlayList(owner.Id, "Two")); - [Fact] - public async Task Handle_ReturnsEveryPlayListPaged() - { - var owner = TestEntities.User(); - await SeedAsync(owner, TestEntities.PlayList(owner.Id, "One"), TestEntities.PlayList(owner.Id, "Two")); + var result = await CreateHandler().Handle(new GetPlayListsQuery(PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetPlayListsQuery(PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(2, result.Value.TotalItemCount); + } - Assert.False(result.IsError); - Assert.Equal(2, result.Value.TotalItemCount); - } + [Fact] + public async Task Handle_NoPlayLists_ReturnsEmptyPage() + { + var result = await CreateHandler().Handle(new GetPlayListsQuery(PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_NoPlayLists_ReturnsEmptyPage() - { - var result = await CreateHandler().Handle(new GetPlayListsQuery(PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Empty(result.Value.Items); - } + Assert.False(result.IsError); + Assert.Empty(result.Value.Items); } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs index 67bab6e..2acaa5b 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/RemoveTrackFromPlayListCommandHandlerTests.cs @@ -4,57 +4,56 @@ using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.PlayLists +namespace Musify.Application.Tests.PlayLists; + +public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase { - public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase + private RemoveTrackFromPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_TrackInPlayList_RemovesTheLink() { - private RemoveTrackFromPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger()); + 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 }); - [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 }); + var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, track.Id), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Empty(Database.PlayListHasTracks); + } - Assert.False(result.IsError); - Assert.Empty(Database.PlayListHasTracks); - } + [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_PlayListMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_NotOwner_ReturnsUnauthorized() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, stranger, playList); - [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() - { - var owner = TestEntities.User(1, "owner"); - var stranger = TestEntities.User(2, "stranger"); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, stranger, playList); + var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); - } + [Fact] + public async Task Handle_TrackNotLinked_ReturnsNotFound() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, playList); - [Fact] - public async Task Handle_TrackNotLinked_ReturnsNotFound() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, playList); + var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs index 7a73cb4..78eab3f 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/RequestPlayListPictureUploadCommandHandlerTests.cs @@ -8,68 +8,67 @@ using Musify.Application.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.PlayLists +namespace Musify.Application.Tests.PlayLists; + +public sealed class RequestPlayListPictureUploadCommandHandlerTests : HandlerTestBase { - public sealed class RequestPlayListPictureUploadCommandHandlerTests : HandlerTestBase + private readonly IStorageService storageService = Substitute.For(); + + private RequestPlayListPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) { - private readonly IStorageService storageService = Substitute.For(); + storageService + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://storage.musify.test/presigned-upload"); - private RequestPlayListPictureUploadCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) - { - storageService - .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns("https://storage.musify.test/presigned-upload"); + return new RequestPlayListPictureUploadCommandHandler( + Database, + new UploadIntentValidator(Database, storageService), + storageService, + NoOpLogger(), + TestConfigurations.Storage(), + TestConfigurations.PlayList(), + uploadIntentConfig ?? TestConfigurations.UploadIntent()); + } - return new RequestPlayListPictureUploadCommandHandler( - Database, - new UploadIntentValidator(Database, storageService), - storageService, - NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.PlayList(), - uploadIntentConfig ?? TestConfigurations.UploadIntent()); - } + [Fact] + public async Task Handle_UserExists_IssuesUploadUrlAndCreatesIntent() + { + var user = TestEntities.User(); + await SeedAsync(user); - [Fact] - public async Task Handle_UserExists_IssuesUploadUrlAndCreatesIntent() - { - var user = TestEntities.User(); - await SeedAsync(user); + var command = new RequestPlayListPictureUploadCommand(user.Id, "webp", "image/webp"); - var command = new RequestPlayListPictureUploadCommand(user.Id, "webp", "image/webp"); + 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.UploadUrl); + Assert.Single(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); + } - Assert.False(result.IsError); - Assert.Equal("https://storage.musify.test/presigned-upload", result.Value.UploadUrl); - Assert.Single(await Database.UploadIntents.ToListAsync(TestContext.Current.CancellationToken)); - } + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var command = new RequestPlayListPictureUploadCommand(404, "webp", "image/webp"); - [Fact] - public async Task Handle_UserMissing_ReturnsNotFound() - { - var command = new RequestPlayListPictureUploadCommand(404, "webp", "image/webp"); + 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_UserOverByteQuota_ReturnsValidationErrorAndCreatesNoIntent() + { + var config = TestConfigurations.UploadIntent(); + config.MaxActiveUploadBytesPerUser = 100; - [Fact] - public async Task Handle_UserOverByteQuota_ReturnsValidationErrorAndCreatesNoIntent() - { - var config = TestConfigurations.UploadIntent(); - config.MaxActiveUploadBytesPerUser = 100; + var user = TestEntities.User(); + await SeedAsync(user); - var user = TestEntities.User(); - await SeedAsync(user); + var command = new RequestPlayListPictureUploadCommand(user.Id, "webp", "image/webp", ExpectedSizeBytes: 1_000_000); - var command = new RequestPlayListPictureUploadCommand(user.Id, "webp", "image/webp", ExpectedSizeBytes: 1_000_000); + 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.Empty(await Database.UploadIntents.ToListAsync(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 ccee5d0..356c69d 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/UpdatePlayListCommandHandlerTests.cs @@ -6,131 +6,130 @@ using Musify.Application.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.PlayLists +namespace Musify.Application.Tests.PlayLists; + +public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase { - public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase + private readonly IEventBus eventBus = Substitute.For(); + private readonly IStorageService storageService = Substitute.For(); + + private UpdatePlayListCommandHandler CreateHandler() => new( + eventBus, + Database, + new UploadIntentValidator(Database, storageService), + NoOpLogger(), + TestConfigurations.Storage(), + TestConfigurations.PlayList(), + TestConfigurations.UploadIntent()); + + [Fact] + public async Task Handle_Owner_RenamesPlayList() { - private readonly IEventBus eventBus = Substitute.For(); - private readonly IStorageService storageService = Substitute.For(); + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, "Old Name"); + await SeedAsync(owner, playList); - private UpdatePlayListCommandHandler CreateHandler() => new( - eventBus, - Database, - new UploadIntentValidator(Database, storageService), - NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.PlayList(), - TestConfigurations.UploadIntent()); + var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null); - [Fact] - public async Task Handle_Owner_RenamesPlayList() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, "Old Name"); - 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("New Name", result.Value.Name); + } - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_PlayListMissing_ReturnsNotFound() + { + var command = new UpdatePlayListCommand(1, Guid.NewGuid(), "New Name", null, null); - Assert.False(result.IsError); - Assert.Equal("New Name", result.Value.Name); - } + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_PlayListMissing_ReturnsNotFound() - { - var command = new UpdatePlayListCommand(1, Guid.NewGuid(), "New Name", null, null); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NotOwner_ReturnsUnauthorized() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var playList = TestEntities.PlayList(owner.Id); + await SeedAsync(owner, stranger, playList); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var command = new UpdatePlayListCommand(stranger.Id, playList.Id, "Hijacked", null, null); - [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() - { - var owner = TestEntities.User(1, "owner"); - var stranger = TestEntities.User(2, "stranger"); - var playList = TestEntities.PlayList(owner.Id); - await SeedAsync(owner, stranger, playList); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var command = new UpdatePlayListCommand(stranger.Id, playList.Id, "Hijacked", null, null); + Assert.Equal(ErrorType.Unauthorized, 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.Equal(ErrorType.Unauthorized, result.FirstError.Type); - } + var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, null, Musify.Domain.ValueObjects.PlaylistVisibility.Public); - [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.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_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_BlankNameAndDescription_LeavesThemUnchanged() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, "Original Name", "Original description"); + await SeedAsync(owner, playList); - Assert.False(result.IsError); - Assert.Equal(Musify.Domain.ValueObjects.PlaylistVisibility.Public, result.Value.Visibility); - } + var command = new UpdatePlayListCommand(owner.Id, playList.Id, " ", " ", null); - [Fact] - public async Task Handle_BlankNameAndDescription_LeavesThemUnchanged() - { - var owner = TestEntities.User(); - var playList = TestEntities.PlayList(owner.Id, "Original Name", "Original description"); - await SeedAsync(owner, playList); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var command = new UpdatePlayListCommand(owner.Id, playList.Id, " ", " ", null); + Assert.False(result.IsError); + Assert.Equal("Original Name", result.Value.Name); + Assert.Equal("Original description", result.Value.Description); + } - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_PlayListWithoutPictures_ValidPictureIntent_SetsPictureAndPublishesSourceEvent() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); - Assert.False(result.IsError); - Assert.Equal("Original Name", result.Value.Name); - Assert.Equal("Original description", result.Value.Description); - } + 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); - [Fact] - public async Task Handle_PlayListWithoutPictures_ValidPictureIntent_SetsPictureAndPublishesSourceEvent() - { - storageService - .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ObjectMetaData("image/webp", 1024)); + var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, intent.Id); - 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); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - 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.Equal("new-cover.webp", 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()); - } + Assert.False(result.IsError); + Assert.Equal("new-cover.webp", 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 23e4924..5fbecf3 100644 --- a/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs @@ -6,94 +6,93 @@ using Musify.Application.Tests.TestSupport; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Services +namespace Musify.Application.Tests.Services; + +public sealed class TrackStreamIssuerTests : HandlerTestBase { - public sealed class TrackStreamIssuerTests : HandlerTestBase + private readonly IStreamTicketService ticketService = Substitute.For(); + + private TrackStreamIssuer CreateIssuer(PlaybackConfiguration? playback = null) => new( + Database, + ticketService, + TestConfigurations.Track(), + TestConfigurations.StreamGateway("https://stream.musify.test/"), + playback ?? TestConfigurations.Playback()); + + [Fact] + public async Task IssueAsync_BuildsManifestUrlAndRecordsListeningHistory() { - private readonly IStreamTicketService ticketService = Substitute.For(); + ticketService.IssueTicket(7, Arg.Any(), Arg.Any()).Returns(new StreamTicket("signed-token", 120)); + var owner = TestEntities.User(7); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); - private TrackStreamIssuer CreateIssuer(PlaybackConfiguration? playback = null) => new( - Database, - ticketService, - TestConfigurations.Track(), - TestConfigurations.StreamGateway("https://stream.musify.test/"), - playback ?? TestConfigurations.Playback()); + var response = await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 7, TestContext.Current.CancellationToken); - [Fact] - public async Task IssueAsync_BuildsManifestUrlAndRecordsListeningHistory() - { - ticketService.IssueTicket(7, Arg.Any(), Arg.Any()).Returns(new StreamTicket("signed-token", 120)); - var owner = TestEntities.User(7); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + Assert.Equal("signed-token", response.Ticket); + Assert.Equal(120, response.ExpiresInSeconds); + Assert.Equal( + "https://stream.musify.test/media/Tracks/ProcessedAudios/folder-42/audio.m4a", + response.ManifestUrl); - var response = await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 7, TestContext.Current.CancellationToken); + var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal(7, history.UserId); + Assert.Equal(track.Id, history.TrackId); + } - Assert.Equal("signed-token", response.Ticket); - Assert.Equal(120, response.ExpiresInSeconds); - Assert.Equal( - "https://stream.musify.test/media/Tracks/ProcessedAudios/folder-42/audio.m4a", - response.ManifestUrl); + [Fact] + public async Task IssueAsync_PassesTheProcessedAudioKeyPrefixToTheTicketService() + { + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); + var owner = TestEntities.User(1); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); - var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); - Assert.Equal(7, history.UserId); - Assert.Equal(track.Id, history.TrackId); - } + await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken); - [Fact] - public async Task IssueAsync_PassesTheProcessedAudioKeyPrefixToTheTicketService() - { - ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); - var owner = TestEntities.User(1); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null); + } - await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken); + [Fact] + public async Task IssueAsync_AnonymousUser_SkipsListeningHistoryAndNeverThrows() + { + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); + var owner = TestEntities.User(1); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); - ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null); - } + var response = await CreateIssuer(TestConfigurations.Playback(allowAnonymousListening: true)) + .IssueAsync(track.Id, "folder-42", userId: null, TestContext.Current.CancellationToken); - [Fact] - public async Task IssueAsync_AnonymousUser_SkipsListeningHistoryAndNeverThrows() - { - ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); - var owner = TestEntities.User(1); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + Assert.Equal("token", response.Ticket); + Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); + } - var response = await CreateIssuer(TestConfigurations.Playback(allowAnonymousListening: true)) - .IssueAsync(track.Id, "folder-42", userId: null, TestContext.Current.CancellationToken); + [Fact] + public async Task IssueAsync_AnonymousUserWithFragmentConfigured_PassesTheByteCapToTheTicketService() + { + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); + var owner = TestEntities.User(1); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); - Assert.Equal("token", response.Ticket); - Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); - } + var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); + await CreateIssuer(playback).IssueAsync(track.Id, "folder-42", userId: null, TestContext.Current.CancellationToken); - [Fact] - public async Task IssueAsync_AnonymousUserWithFragmentConfigured_PassesTheByteCapToTheTicketService() - { - ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); - var owner = TestEntities.User(1); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + ticketService.Received(1).IssueTicket(null, "Tracks/ProcessedAudios/folder-42/", 30L * playback.EstimatedAudioBytesPerSecond); + } - var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); - await CreateIssuer(playback).IssueAsync(track.Id, "folder-42", userId: null, TestContext.Current.CancellationToken); + [Fact] + public async Task IssueAsync_AuthenticatedUser_IgnoresTheAnonymousFragmentCap() + { + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); + var owner = TestEntities.User(1); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); - ticketService.Received(1).IssueTicket(null, "Tracks/ProcessedAudios/folder-42/", 30L * playback.EstimatedAudioBytesPerSecond); - } + var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); + await CreateIssuer(playback).IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken); - [Fact] - public async Task IssueAsync_AuthenticatedUser_IgnoresTheAnonymousFragmentCap() - { - ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); - var owner = TestEntities.User(1); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); - - var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); - await CreateIssuer(playback).IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken); - - ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", 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 441885b..e526555 100644 --- a/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs +++ b/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs @@ -6,141 +6,140 @@ using Musify.Domain.ValueObjects; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Services +namespace Musify.Application.Tests.Services; + +public sealed class UploadIntentValidatorTests : HandlerTestBase { - public sealed class UploadIntentValidatorTests : HandlerTestBase + private readonly IStorageService storageService = Substitute.For(); + private readonly Domain.Entities.User owner = TestEntities.User(); + + private UploadIntentValidator CreateValidator() => new(Database, storageService); + + [Fact] + public async Task ValidateAndLoadAsync_UploadedObjectWithinLimits_ReturnsTheIntent() { - private readonly IStorageService storageService = Substitute.For(); - private readonly Domain.Entities.User owner = TestEntities.User(); + storageService + .HeadObjectAsync("bucket", "temp/object.webp", Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 2048)); - private UploadIntentValidator CreateValidator() => new(Database, storageService); + var intent = TestEntities.UploadIntent(owner.Id, bucket: "bucket", key: "temp/object.webp"); + await SeedAsync(owner, intent); - [Fact] - public async Task ValidateAndLoadAsync_UploadedObjectWithinLimits_ReturnsTheIntent() - { - storageService - .HeadObjectAsync("bucket", "temp/object.webp", Arg.Any()) - .Returns(new ObjectMetaData("image/webp", 2048)); + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); - var intent = TestEntities.UploadIntent(owner.Id, bucket: "bucket", key: "temp/object.webp"); - await SeedAsync(owner, intent); + Assert.False(result.IsError); + Assert.Equal(intent.Id, result.Value.Id); + } - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); + [Fact] + public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound() + { + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), Guid.NewGuid(), owner.Id, TestContext.Current.CancellationToken); - Assert.False(result.IsError); - Assert.Equal(intent.Id, result.Value.Id); - } + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - [Fact] - public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound() - { - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), Guid.NewGuid(), owner.Id, TestContext.Current.CancellationToken); + [Fact] + public async Task ValidateAndLoadAsync_IntentBelongsToAnotherUser_ReturnsNotFound() + { + var stranger = TestEntities.User(2, "stranger"); + var intent = TestEntities.UploadIntent(owner.Id); + await SeedAsync(owner, stranger, intent); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, stranger.Id, TestContext.Current.CancellationToken); - [Fact] - public async Task ValidateAndLoadAsync_IntentBelongsToAnotherUser_ReturnsNotFound() - { - var stranger = TestEntities.User(2, "stranger"); - var intent = TestEntities.UploadIntent(owner.Id); - await SeedAsync(owner, stranger, intent); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, stranger.Id, TestContext.Current.CancellationToken); + [Fact] + public async Task ValidateAndLoadAsync_AlreadyConsumed_ReturnsConflict() + { + var intent = TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed); + await SeedAsync(owner, intent); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); - [Fact] - public async Task ValidateAndLoadAsync_AlreadyConsumed_ReturnsConflict() - { - var intent = TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed); - await SeedAsync(owner, intent); + Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + } - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); + [Fact] + public async Task ValidateAndLoadAsync_PastExpiry_ReturnsValidationError() + { + var intent = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-1)); + await SeedAsync(owner, intent); - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); - } + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); - [Fact] - public async Task ValidateAndLoadAsync_PastExpiry_ReturnsValidationError() - { - var intent = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-1)); - await SeedAsync(owner, intent); + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); + [Fact] + public async Task ValidateAndLoadAsync_ObjectNeverUploaded_ReturnsNotFound() + { + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((ObjectMetaData?)null); - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } + var intent = TestEntities.UploadIntent(owner.Id); + await SeedAsync(owner, intent); - [Fact] - public async Task ValidateAndLoadAsync_ObjectNeverUploaded_ReturnsNotFound() - { - storageService - .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns((ObjectMetaData?)null); + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); - var intent = TestEntities.UploadIntent(owner.Id); - await SeedAsync(owner, intent); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); + [Fact] + public async Task ValidateAndLoadAsync_UploadedObjectExceedsMaxSize_ReturnsValidationError() + { + var config = TestConfigurations.UploadIntent(); + config.MaxUploadBytes = 1024; - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 2048)); - [Fact] - public async Task ValidateAndLoadAsync_UploadedObjectExceedsMaxSize_ReturnsValidationError() - { - var config = TestConfigurations.UploadIntent(); - config.MaxUploadBytes = 1024; + var intent = TestEntities.UploadIntent(owner.Id); + await SeedAsync(owner, intent); - storageService - .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ObjectMetaData("image/webp", 2048)); + var result = await CreateValidator().ValidateAndLoadAsync(config, intent.Id, owner.Id, TestContext.Current.CancellationToken); - var intent = TestEntities.UploadIntent(owner.Id); - await SeedAsync(owner, intent); + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } - var result = await CreateValidator().ValidateAndLoadAsync(config, intent.Id, owner.Id, TestContext.Current.CancellationToken); + [Fact] + public async Task CheckQuotaAsync_WithinLimits_ReturnsSuccess() + { + await SeedAsync(owner); - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } + var result = await CreateValidator().CheckQuotaAsync(TestConfigurations.UploadIntent(), owner.Id, requiredBytes: 1024, requiredIntentCount: 1, TestContext.Current.CancellationToken); - [Fact] - public async Task CheckQuotaAsync_WithinLimits_ReturnsSuccess() - { - await SeedAsync(owner); + Assert.False(result.IsError); + } - var result = await CreateValidator().CheckQuotaAsync(TestConfigurations.UploadIntent(), owner.Id, requiredBytes: 1024, requiredIntentCount: 1, TestContext.Current.CancellationToken); + [Fact] + public async Task CheckQuotaAsync_TooManyActiveIntents_ReturnsValidationError() + { + var config = TestConfigurations.UploadIntent(); + config.MaxActiveUploadIntentsPerUser = 1; + await SeedAsync(owner, TestEntities.UploadIntent(owner.Id)); - Assert.False(result.IsError); - } + var result = await CreateValidator().CheckQuotaAsync(config, owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken); - [Fact] - public async Task CheckQuotaAsync_TooManyActiveIntents_ReturnsValidationError() - { - var config = TestConfigurations.UploadIntent(); - config.MaxActiveUploadIntentsPerUser = 1; - await SeedAsync(owner, TestEntities.UploadIntent(owner.Id)); + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } - var result = await CreateValidator().CheckQuotaAsync(config, owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken); + [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)); - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } + var result = await CreateValidator().CheckQuotaAsync(config, owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken); - [Fact] - public async Task CheckQuotaAsync_ExpiredOrConsumedIntentsDoNotCountTowardsTheLimit() - { - var config = TestConfigurations.UploadIntent(); - config.MaxActiveUploadIntentsPerUser = 1; - await SeedAsync( - owner, - TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Expired), - TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed)); - - var result = await CreateValidator().CheckQuotaAsync(config, owner.Id, requiredBytes: 1, requiredIntentCount: 1, TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - } + 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 447256b..8311815 100644 --- a/backend/Tests/Musify.Application.Tests/Shared/StorageKeyTests.cs +++ b/backend/Tests/Musify.Application.Tests/Shared/StorageKeyTests.cs @@ -1,41 +1,40 @@ using Musify.Application.Shared; using Xunit; -namespace Musify.Application.Tests.Shared +namespace Musify.Application.Tests.Shared; + +public sealed class StorageKeyTests { - public sealed class StorageKeyTests + [Fact] + public void Combine_MultipleSegments_JoinsThemWithForwardSlashes() { - [Fact] - public void Combine_MultipleSegments_JoinsThemWithForwardSlashes() - { - var key = StorageKey.Combine("Tracks", "SmallPictures", "cover.webp"); + var key = StorageKey.Combine("Tracks", "SmallPictures", "cover.webp"); - Assert.Equal("Tracks/SmallPictures/cover.webp", key); - } + Assert.Equal("Tracks/SmallPictures/cover.webp", key); + } - [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 dae34fd..4ad7a9e 100644 --- a/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs +++ b/backend/Tests/Musify.Application.Tests/Shared/TrackApplicationResponseTests.cs @@ -2,32 +2,31 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks.Responses; using Xunit; -namespace Musify.Application.Tests.Shared +namespace Musify.Application.Tests.Shared; + +public sealed class TrackApplicationResponseTests { - public sealed class TrackApplicationResponseTests + [Fact] + public void FromEntity_UsesOwnerNameAsArtist() { - [Fact] - public void FromEntity_UsesOwnerNameAsArtist() - { - var owner = TestEntities.User(1, "The Owner"); - var track = TestEntities.Track(owner); + 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 653dfeb..42b925f 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/HandlerTestBase.cs @@ -2,26 +2,25 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Xunit; -namespace Musify.Application.Tests.TestSupport +namespace Musify.Application.Tests.TestSupport; + +public abstract class HandlerTestBase : IAsyncLifetime { - public abstract class HandlerTestBase : IAsyncLifetime + private TestDatabase database = null!; + + protected TestDatabase Database => database; + + public async ValueTask InitializeAsync() => database = await TestDatabase.CreateAsync(); + + public ValueTask DisposeAsync() => database.DisposeAsync(); + + protected static ILogger NoOpLogger() => NullLogger.Instance; + + protected async Task SeedAsync(params object[] entities) { - private TestDatabase database = null!; + foreach (var entity in entities) + Database.Add(entity); - protected TestDatabase Database => database; - - public async ValueTask InitializeAsync() => database = await TestDatabase.CreateAsync(); - - public ValueTask DisposeAsync() => 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); - } + 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 2d45ebe..56bb542 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs @@ -1,26 +1,25 @@ using Musify.Application.Configuration; -namespace Musify.Application.Tests.TestSupport +namespace Musify.Application.Tests.TestSupport; + +public static class TestConfigurations { - public static class TestConfigurations - { - public static ApplicationStorageConfiguration Storage(string bucket = "test-bucket") => - new() { Bucket = bucket }; + 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 177c128..6ddf063 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestDatabase.cs @@ -3,180 +3,179 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Domain.Entities; -namespace Musify.Application.Tests.TestSupport +namespace Musify.Application.Tests.TestSupport; + +public sealed class TestDatabase : DbContext, IDatabase { - public sealed class TestDatabase : DbContext, IDatabase + private readonly SqliteConnection connection; + + private TestDatabase(SqliteConnection connection, DbContextOptions options) + : base(options) { - private readonly SqliteConnection connection; + this.connection = connection; + } - private TestDatabase(SqliteConnection connection, DbContextOptions options) - : base(options) + 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); + + modelBuilder.Entity().OwnsOne(track => track.Pictures); + modelBuilder.Entity().Navigation(track => track.Pictures).IsRequired(); + + modelBuilder.Entity().OwnsOne(track => track.Audio); + modelBuilder.Entity().Navigation(track => track.Audio).IsRequired(); + + modelBuilder.Entity() + .HasOne(track => track.Owner) + .WithMany() + .HasForeignKey(track => track.OwnerUserId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity().OwnsOne(playList => playList.Pictures, pictures => { - this.connection = connection; - } + pictures.Property(p => p.OriginalName).IsRequired(false); + pictures.Property(p => p.SmallName).IsRequired(false); + pictures.Property(p => p.MediumName).IsRequired(false); + pictures.Property(p => p.LargeName).IsRequired(false); + }); + modelBuilder.Entity().Navigation(playList => playList.Pictures).IsRequired(false); - public static async Task CreateAsync() - { - var connection = new SqliteConnection("Filename=:memory:"); - await connection.OpenAsync(); + modelBuilder.Entity().OwnsOne(album => album.Pictures); + modelBuilder.Entity().Navigation(album => album.Pictures).IsRequired(false); - var options = new DbContextOptionsBuilder() - .UseSqlite(connection) - .Options; + modelBuilder.Entity() + .HasOne(album => album.Owner) + .WithMany() + .HasForeignKey(album => album.OwnerUserId) + .OnDelete(DeleteBehavior.Restrict); - var database = new TestDatabase(connection, options); - await database.Database.EnsureCreatedAsync(); - return database; - } + modelBuilder.Entity() + .HasOne(albumTrack => albumTrack.Album) + .WithMany(album => album.AlbumTracks) + .HasForeignKey(albumTrack => albumTrack.AlbumId) + .OnDelete(DeleteBehavior.Cascade); - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); + modelBuilder.Entity() + .HasOne(albumTrack => albumTrack.Track) + .WithMany(track => track.AlbumTracks) + .HasForeignKey(albumTrack => albumTrack.TrackId) + .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity().OwnsOne(track => track.Pictures); - modelBuilder.Entity().Navigation(track => track.Pictures).IsRequired(); + modelBuilder.Entity() + .HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId }) + .IsUnique(); - modelBuilder.Entity().OwnsOne(track => track.Audio); - modelBuilder.Entity().Navigation(track => track.Audio).IsRequired(); + modelBuilder.Entity() + .HasIndex(mix => new { mix.UserId, mix.Position }); - modelBuilder.Entity() - .HasOne(track => track.Owner) - .WithMany() - .HasForeignKey(track => track.OwnerUserId) - .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(item => item.Mix) + .WithMany(mix => mix.Items) + .HasForeignKey(item => item.MixId) + .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity().OwnsOne(playList => playList.Pictures, pictures => - { - pictures.Property(p => p.OriginalName).IsRequired(false); - pictures.Property(p => p.SmallName).IsRequired(false); - pictures.Property(p => p.MediumName).IsRequired(false); - pictures.Property(p => p.LargeName).IsRequired(false); - }); - modelBuilder.Entity().Navigation(playList => playList.Pictures).IsRequired(false); + modelBuilder.Entity() + .HasOne(item => item.Track) + .WithMany() + .HasForeignKey(item => item.TrackId) + .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity().OwnsOne(album => album.Pictures); - modelBuilder.Entity().Navigation(album => album.Pictures).IsRequired(false); + modelBuilder.Entity() + .HasIndex(item => new { item.MixId, item.Position }); - modelBuilder.Entity() - .HasOne(album => album.Owner) - .WithMany() - .HasForeignKey(album => album.OwnerUserId) - .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasIndex(like => new { like.UserId, like.TrackId }) + .IsUnique(); - modelBuilder.Entity() - .HasOne(albumTrack => albumTrack.Album) - .WithMany(album => album.AlbumTracks) - .HasForeignKey(albumTrack => albumTrack.AlbumId) - .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(trackTag => trackTag.Track) + .WithMany(track => track.Tags) + .HasForeignKey(trackTag => trackTag.TrackId) + .OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity() - .HasOne(albumTrack => albumTrack.Track) - .WithMany(track => track.AlbumTracks) - .HasForeignKey(albumTrack => albumTrack.TrackId) - .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag }) + .IsUnique(); - modelBuilder.Entity() - .HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId }) - .IsUnique(); + modelBuilder.Entity() + .HasOne(follow => follow.Follower) + .WithMany() + .HasForeignKey(follow => follow.FollowerId) + .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity() - .HasIndex(mix => new { mix.UserId, mix.Position }); + modelBuilder.Entity() + .HasOne(follow => follow.Followed) + .WithMany() + .HasForeignKey(follow => follow.FollowedId) + .OnDelete(DeleteBehavior.Restrict); - modelBuilder.Entity() - .HasOne(item => item.Mix) - .WithMany(mix => mix.Items) - .HasForeignKey(item => item.MixId) - .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasIndex(follow => new { follow.FollowerId, follow.FollowedId }) + .IsUnique(); + } - modelBuilder.Entity() - .HasOne(item => item.Track) - .WithMany() - .HasForeignKey(item => item.TrackId) - .OnDelete(DeleteBehavior.Cascade); + public DbSet Users => Set(); - modelBuilder.Entity() - .HasIndex(item => new { item.MixId, item.Position }); + public DbSet PlayLists => Set(); - modelBuilder.Entity() - .HasIndex(like => new { like.UserId, like.TrackId }) - .IsUnique(); + public DbSet Albums => Set(); - modelBuilder.Entity() - .HasOne(trackTag => trackTag.Track) - .WithMany(track => track.Tags) - .HasForeignKey(trackTag => trackTag.TrackId) - .OnDelete(DeleteBehavior.Cascade); + public DbSet AlbumHasTracks => Set(); - modelBuilder.Entity() - .HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag }) - .IsUnique(); + public DbSet Tracks => Set(); - modelBuilder.Entity() - .HasOne(follow => follow.Follower) - .WithMany() - .HasForeignKey(follow => follow.FollowerId) - .OnDelete(DeleteBehavior.Restrict); + public DbSet UserHasTracks => Set(); - modelBuilder.Entity() - .HasOne(follow => follow.Followed) - .WithMany() - .HasForeignKey(follow => follow.FollowedId) - .OnDelete(DeleteBehavior.Restrict); + public DbSet PlayListHasTracks => Set(); - modelBuilder.Entity() - .HasIndex(follow => new { follow.FollowerId, follow.FollowedId }) - .IsUnique(); - } + public DbSet Mixes => Set(); - public DbSet Users => Set(); + public DbSet MixItems => Set(); - public DbSet PlayLists => Set(); + public DbSet Uploads => Set(); - public DbSet Albums => Set(); + public DbSet UploadIntents => Set(); - public DbSet AlbumHasTracks => Set(); + public DbSet ListeningHistories => Set(); - public DbSet Tracks => Set(); + public DbSet TrackLikes => Set(); - public DbSet UserHasTracks => Set(); + public DbSet TrackTags => Set(); - public DbSet PlayListHasTracks => Set(); + public DbSet UserFollows => Set(); - public DbSet Mixes => Set(); + public async Task BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) + { + var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); + return new SqliteDatabaseTransaction(transaction); + } - public DbSet MixItems => Set(); + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + await connection.DisposeAsync(); + } - public DbSet Uploads => Set(); + private sealed class SqliteDatabaseTransaction(Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction inner) + : IDatabaseTransaction + { + public Task CommitAsync(CancellationToken cancellationToken = default) => + inner.CommitAsync(cancellationToken); - 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 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(); - } + 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 c10efba..bf5b8f8 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs @@ -1,194 +1,193 @@ using Musify.Domain.Entities; using Musify.Domain.ValueObjects; -namespace Musify.Application.Tests.TestSupport +namespace Musify.Application.Tests.TestSupport; + +public static class TestEntities { - 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() { - 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 - }; + OriginalName = "original.webp", + SmallName = "small.webp", + MediumName = "medium.webp", + LargeName = "large.webp", + ProcessingStatus = ProcessingStatus.Completed + }; - public static TrackPictures ProcessedPictures() => new() + 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 { - OriginalName = "original.webp", - SmallName = "small.webp", - MediumName = "medium.webp", - LargeName = "large.webp", - ProcessingStatus = ProcessingStatus.Completed + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + DurationSeconds = durationSeconds, + LifeCycleStatus = lifeCycleStatus, + IsExplicit = isExplicit, + OwnerUserId = owner.Id, + Owner = owner, + Pictures = pictures ?? ProcessedPictures(), + Audio = audio ?? ProcessedAudio() }; - public static TrackPictures PendingPictures() => new() - { - 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 - }; - - 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 PlayListPictures PlayListPictures() => new() - { - OriginalName = "original.webp", - SmallName = "small.webp", - MediumName = "medium.webp", - LargeName = "large.webp" - }; - - public static PlayList PlayList( - long userId, - string name = "Test Playlist", - string? description = null, - PlayListPictures? pictures = null, - LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active, - PlaylistVisibility visibility = PlaylistVisibility.Private) => - new() - { - UserId = userId, - Name = name, - NormalizedName = name.ToUpperInvariant(), - Description = description, - Pictures = pictures ?? PlayListPictures(), - LifeCycleStatus = lifeCycleStatus, - Visibility = visibility - }; - - public static AlbumPictures 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, - AlbumPictures? 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, - string title = "Test Mix", - string? subtitle = null, - int position = 0) => - new() - { - UserId = userId, - Title = title, - Subtitle = subtitle, - Position = position - }; - - public static MixItem MixItem( - Guid mixId, - 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 - }; + return track; } + + public static PlayListPictures PlayListPictures() => new() + { + OriginalName = "original.webp", + SmallName = "small.webp", + MediumName = "medium.webp", + LargeName = "large.webp" + }; + + public static PlayList PlayList( + long userId, + string name = "Test Playlist", + string? description = null, + PlayListPictures? pictures = null, + LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active, + PlaylistVisibility visibility = PlaylistVisibility.Private) => + new() + { + UserId = userId, + Name = name, + NormalizedName = name.ToUpperInvariant(), + Description = description, + Pictures = pictures ?? PlayListPictures(), + LifeCycleStatus = lifeCycleStatus, + Visibility = visibility + }; + + public static AlbumPictures 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, + AlbumPictures? 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, + string title = "Test Mix", + string? subtitle = null, + int position = 0) => + new() + { + UserId = userId, + Title = title, + Subtitle = subtitle, + Position = position + }; + + public static MixItem MixItem( + Guid mixId, + 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 6b9a610..76a1ea8 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs @@ -8,143 +8,142 @@ using Musify.Domain.ValueObjects; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Tracks +namespace Musify.Application.Tests.Tracks; + +public sealed class CreateTrackCommandHandlerTests : HandlerTestBase { - public sealed class CreateTrackCommandHandlerTests : HandlerTestBase + private readonly IEventBus eventBus = Substitute.For(); + private readonly IStorageService storageService = Substitute.For(); + + private CreateTrackCommandHandler CreateHandler() { - private readonly IEventBus eventBus = Substitute.For(); - private readonly IStorageService storageService = Substitute.For(); + storageService + .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new ObjectMetaData("image/webp", 1024)); - private CreateTrackCommandHandler CreateHandler() - { - storageService - .HeadObjectAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(new ObjectMetaData("image/webp", 1024)); + return new CreateTrackCommandHandler( + Database, + eventBus, + new UploadIntentValidator(Database, storageService), + NoOpLogger(), + TestConfigurations.Storage(), + TestConfigurations.Track(), + TestConfigurations.UploadIntent()); + } - return new CreateTrackCommandHandler( - Database, - eventBus, - new UploadIntentValidator(Database, storageService), - NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.Track(), - TestConfigurations.UploadIntent()); - } + 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")); - 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")); + [Fact] + public async Task Handle_ValidIntents_CreatesTrackAndPublishesEvent() + { + var user = TestEntities.User(); + var (picture, audio) = SeedIntents(user.Id); + await SeedAsync(user, picture, audio); - [Fact] - public async Task Handle_ValidIntents_CreatesTrackAndPublishesEvent() - { - var user = TestEntities.User(); - var (picture, audio) = SeedIntents(user.Id); - await SeedAsync(user, picture, audio); + var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop, Genre.Rock]); - var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop, Genre.Rock]); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + 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()); - 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 stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus); + } - var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus); - } + [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); - [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 command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop], isExplicit); - var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop], isExplicit); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + 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); + } - 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); - } + [Fact] + public async Task Handle_NoTags_ReturnsValidationError() + { + var user = TestEntities.User(); + var (picture, audio) = SeedIntents(user.Id); + await SeedAsync(user, picture, audio); - [Fact] - public async Task Handle_NoTags_ReturnsValidationError() - { - var user = TestEntities.User(); - var (picture, audio) = SeedIntents(user.Id); - await SeedAsync(user, picture, audio); + var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, []); - var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, []); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } + [Fact] + public async Task Handle_IncompatibleTags_ReturnsValidationError() + { + var user = TestEntities.User(); + var (picture, audio) = SeedIntents(user.Id); + await SeedAsync(user, picture, audio); - [Fact] - public async Task Handle_IncompatibleTags_ReturnsValidationError() - { - var user = TestEntities.User(); - var (picture, audio) = SeedIntents(user.Id); - await SeedAsync(user, picture, audio); + var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Classical, Genre.Metal]); - var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Classical, Genre.Metal]); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var command = new CreateTrackCommand(404, "Orphan Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Pop]); - [Fact] - public async Task Handle_UserMissing_ReturnsNotFound() - { - var command = new CreateTrackCommand(404, "Orphan Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Pop]); + 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_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); - [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 command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop]); - var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop]); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + Assert.True(result.IsError); + await eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); + } - Assert.True(result.IsError); - await eventBus.DidNotReceive().PublishAsync(Arg.Any(), Arg.Any()); - } + [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); - [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 command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop]); - var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop]); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); - } + 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 184f3d5..41b449f 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/DeleteStaleUncountedListensCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/DeleteStaleUncountedListensCommandHandlerTests.cs @@ -3,30 +3,29 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; using Xunit; -namespace Musify.Application.Tests.Tracks +namespace Musify.Application.Tests.Tracks; + +public sealed class DeleteStaleUncountedListensCommandHandlerTests : HandlerTestBase { - public sealed class DeleteStaleUncountedListensCommandHandlerTests : HandlerTestBase + private DeleteStaleUncountedListensCommandHandler CreateHandler() => + new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_DeletesOnlyOldUncountedListens() { - private DeleteStaleUncountedListensCommandHandler CreateHandler() => - new(Database, NoOpLogger()); + 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); - [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); + var deleted = await CreateHandler().Handle(new DeleteStaleUncountedListensCommand(), TestContext.Current.CancellationToken); - 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); - } + 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 da76df3..00163a1 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/DeleteTrackCommandHandlerTests.cs @@ -6,61 +6,60 @@ using Musify.Domain.ValueObjects; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Tracks +namespace Musify.Application.Tests.Tracks; + +public sealed class DeleteTrackCommandHandlerTests : HandlerTestBase { - public sealed class DeleteTrackCommandHandlerTests : HandlerTestBase + private readonly IEventBus eventBus = Substitute.For(); + + private DeleteTrackCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); + + [Fact] + public async Task Handle_Owner_MarksTrackAsRemovingAndPublishesEvent() { - private readonly IEventBus eventBus = Substitute.For(); + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); - private DeleteTrackCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger()); + var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_Owner_MarksTrackAsRemovingAndPublishesEvent() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + 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()); + } - var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new DeleteTrackCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken); - 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()); - } + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - [Fact] - public async Task Handle_TrackMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new DeleteTrackCommand(1, Guid.NewGuid()), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_NotOwner_ReturnsUnauthorized() + { + var owner = TestEntities.User(1, "owner"); + var stranger = TestEntities.User(2, "stranger"); + var track = TestEntities.Track(owner); + await SeedAsync(owner, stranger, track); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new DeleteTrackCommand(stranger.Id, track.Id), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_NotOwner_ReturnsUnauthorized() - { - var owner = TestEntities.User(1, "owner"); - var stranger = TestEntities.User(2, "stranger"); - var track = TestEntities.Track(owner); - await SeedAsync(owner, stranger, track); + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } - var result = await CreateHandler().Handle(new DeleteTrackCommand(stranger.Id, track.Id), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_AudioStillProcessing_ReturnsConflict() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio()); + await SeedAsync(owner, track); - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); - } + var result = await CreateHandler().Handle(new DeleteTrackCommand(owner.Id, track.Id), TestContext.Current.CancellationToken); - [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); - } + 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 07d1fdf..88965d9 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetLastTrackListenedByUserIdQueryHandlerTests.cs @@ -3,38 +3,37 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; using Xunit; -namespace Musify.Application.Tests.Tracks +namespace Musify.Application.Tests.Tracks; + +public sealed class GetLastTrackListenedByUserIdQueryHandlerTests : HandlerTestBase { - public sealed class GetLastTrackListenedByUserIdQueryHandlerTests : HandlerTestBase + private GetLastTrackListenedByUserIdQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_MultipleListens_ReturnsMostRecentlyListenedTrack() { - private GetLastTrackListenedByUserIdQueryHandler CreateHandler() => new(Database); + 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)); - [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)); + var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal("Newer listen", result.Value.Title); + } - Assert.False(result.IsError); - Assert.Equal("Newer listen", result.Value.Title); - } + [Fact] + public async Task Handle_NoHistory_ReturnsNotFound() + { + var owner = TestEntities.User(); + await SeedAsync(owner); - [Fact] - public async Task Handle_NoHistory_ReturnsNotFound() - { - var owner = TestEntities.User(); - await SeedAsync(owner); + var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs index 1886f27..d9cf654 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs @@ -2,54 +2,53 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; using Xunit; -namespace Musify.Application.Tests.Tracks +namespace Musify.Application.Tests.Tracks; + +public sealed class GetListeningHistoryQueryHandlerTests : HandlerTestBase { - public sealed class GetListeningHistoryQueryHandlerTests : HandlerTestBase + private GetListeningHistoryQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_MultipleListens_ReturnsDistinctTracksMostRecentFirst() { - private GetListeningHistoryQueryHandler CreateHandler() => new(Database); + 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)); - [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)); + var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken); + Assert.Equal(["Newer listen", "Older listen"], result.Select(track => track.Title)); + } - Assert.Equal(["Newer listen", "Older listen"], result.Select(track => track.Title)); - } + [Fact] + public async Task Handle_NoHistory_ReturnsEmpty() + { + var owner = TestEntities.User(); + await SeedAsync(owner); - [Fact] - public async Task Handle_NoHistory_ReturnsEmpty() - { - var owner = TestEntities.User(); - await SeedAsync(owner); + var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken); + Assert.Empty(result); + } - Assert.Empty(result); - } + [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)); - [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)); + var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken); - - Assert.Equal(["Counted"], result.Select(track => track.Title)); - } + 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 4a5798a..93a1548 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs @@ -4,50 +4,49 @@ using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; using Xunit; -namespace Musify.Application.Tests.Tracks +namespace Musify.Application.Tests.Tracks; + +public sealed class GetTrackByIdQueryHandlerTests : HandlerTestBase { - public sealed class GetTrackByIdQueryHandlerTests : HandlerTestBase + private GetTrackByIdQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_LocalTrack_ReturnsItWithOwnerAsArtist() { - private GetTrackByIdQueryHandler CreateHandler() => new(Database); + var owner = TestEntities.User(1, "artist-name"); + var track = TestEntities.Track(owner, "My Song"); + await SeedAsync(owner, track); - [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); + var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), 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.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); - } + [Fact] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetTrackByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_TrackMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetTrackByIdQuery(Guid.NewGuid()), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [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)); - [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)); + var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken); - - Assert.Equal(2, result.Value.ListensCount); - } + Assert.Equal(2, result.Value.ListensCount); } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs index cd3b6fd..a4a8615 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackCoverQueryHandlerTests.cs @@ -3,50 +3,49 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; using Xunit; -namespace Musify.Application.Tests.Tracks +namespace Musify.Application.Tests.Tracks; + +public sealed class GetTrackCoverQueryHandlerTests : HandlerTestBase { - public sealed class GetTrackCoverQueryHandlerTests : HandlerTestBase + private GetTrackCoverQueryHandler CreateHandler() => + new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.Track()); + + [Theory] + [InlineData("small", "small.webp")] + [InlineData("medium", "medium.webp")] + [InlineData("large", "large.webp")] + [InlineData("unknown-size-falls-back-to-medium", "medium.webp")] + public async Task Handle_ProcessedTrack_ReturnsRequestedSize(string size, string expectedFileName) { - private GetTrackCoverQueryHandler CreateHandler() => - new(Database, TestConfigurations.Storage("covers-bucket"), TestConfigurations.Track()); + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); - [Theory] - [InlineData("small", "small.webp")] - [InlineData("medium", "medium.webp")] - [InlineData("large", "large.webp")] - [InlineData("unknown-size-falls-back-to-medium", "medium.webp")] - public async Task Handle_ProcessedTrack_ReturnsRequestedSize(string size, string expectedFileName) - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, size), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, size), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal("covers-bucket", result.Value.Bucket); + Assert.EndsWith(expectedFileName, result.Value.Key); + Assert.Equal("image/webp", result.Value.ContentType); + } - Assert.False(result.IsError); - Assert.Equal("covers-bucket", result.Value.Bucket); - Assert.EndsWith(expectedFileName, result.Value.Key); - Assert.Equal("image/webp", result.Value.ContentType); - } + [Fact] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetTrackCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_TrackMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetTrackCoverQuery(Guid.NewGuid(), "small"), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_PictureNotProcessedYet_ReturnsNotFound() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner, pictures: TestEntities.PendingPictures()); + await SeedAsync(owner, track); - [Fact] - public async Task Handle_PictureNotProcessedYet_ReturnsNotFound() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner, pictures: TestEntities.PendingPictures()); - await SeedAsync(owner, track); + var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, "small"), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetTrackCoverQuery(track.Id, "small"), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs index de52989..e88e4e9 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs @@ -8,96 +8,95 @@ using Musify.Application.Tracks; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Tracks +namespace Musify.Application.Tests.Tracks; + +public sealed class GetTrackStreamQueryHandlerTests : HandlerTestBase { - public sealed class GetTrackStreamQueryHandlerTests : HandlerTestBase + private readonly IStreamTicketService ticketService = Substitute.For(); + + private GetTrackStreamQueryHandler CreateHandler(PlaybackConfiguration? playback = null) { - private readonly IStreamTicketService ticketService = Substitute.For(); + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("ticket-token", 60)); - private GetTrackStreamQueryHandler CreateHandler(PlaybackConfiguration? playback = null) - { - ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("ticket-token", 60)); + var issuer = new TrackStreamIssuer( + Database, + ticketService, + TestConfigurations.Track(), + TestConfigurations.StreamGateway("https://stream.musify.test"), + playback ?? TestConfigurations.Playback()); - var issuer = new TrackStreamIssuer( - Database, - ticketService, - TestConfigurations.Track(), - TestConfigurations.StreamGateway("https://stream.musify.test"), - playback ?? TestConfigurations.Playback()); + return new GetTrackStreamQueryHandler(Database, issuer, playback ?? TestConfigurations.Playback(), NoOpLogger()); + } - return new GetTrackStreamQueryHandler(Database, issuer, playback ?? TestConfigurations.Playback(), NoOpLogger()); - } + [Fact] + public async Task Handle_ProcessedTrack_IssuesTicketAndRecordsListen() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); - [Fact] - public async Task Handle_ProcessedTrack_IssuesTicketAndRecordsListen() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal("ticket-token", result.Value.Ticket); + Assert.Equal(60, result.Value.ExpiresInSeconds); + Assert.Contains("audio-folder", result.Value.ManifestUrl); - 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 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); + } - 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); - } + [Fact] + public async Task Handle_TrackMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetTrackStreamQuery(Guid.NewGuid(), 1), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_TrackMissing_ReturnsNotFound() - { - var result = await CreateHandler().Handle(new GetTrackStreamQuery(Guid.NewGuid(), 1), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + [Fact] + public async Task Handle_AudioNotProcessedYet_ReturnsConflict() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio()); + await SeedAsync(owner, track); - [Fact] - public async Task Handle_AudioNotProcessedYet_ReturnsConflict() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner, audio: TestEntities.PendingAudio()); - await SeedAsync(owner, track); + var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetTrackStreamQuery(track.Id, owner.Id), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + } - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); - } + [Fact] + public async Task Handle_AnonymousUser_AnonymousListeningDisabled_ReturnsUnauthorized() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); - [Fact] - public async Task Handle_AnonymousUser_AnonymousListeningDisabled_ReturnsUnauthorized() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: false)); - var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: false)); + var result = await handler.Handle(new GetTrackStreamQuery(track.Id, UserId: null), TestContext.Current.CancellationToken); - var result = await handler.Handle(new GetTrackStreamQuery(track.Id, UserId: null), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } - Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); - } + [Fact] + public async Task Handle_AnonymousUser_AnonymousListeningEnabled_IssuesTicketWithoutRecordingHistory() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track); - [Fact] - public async Task Handle_AnonymousUser_AnonymousListeningEnabled_IssuesTicketWithoutRecordingHistory() - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track); + var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true)); - var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true)); + var result = await handler.Handle(new GetTrackStreamQuery(track.Id, UserId: null), TestContext.Current.CancellationToken); - var result = await handler.Handle(new GetTrackStreamQuery(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)); - } + 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/Tracks/GetTracksByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs index 1e6f20e..9be31e2 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksByUserIdQueryHandlerTests.cs @@ -3,44 +3,43 @@ using Musify.Application.Tracks; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Tracks +namespace Musify.Application.Tests.Tracks; + +public sealed class GetTracksByUserIdQueryHandlerTests : HandlerTestBase { - public sealed class GetTracksByUserIdQueryHandlerTests : HandlerTestBase + private GetTracksByUserIdQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_ReturnsOnlyTracksLinkedToThatUser() { - private GetTracksByUserIdQueryHandler CreateHandler() => new(Database); + 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, + new UserHasTrack { UserId = owner.Id, TrackId = mine.Id }, + new UserHasTrack { UserId = other.Id, TrackId = theirs.Id }); - [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, - new UserHasTrack { UserId = owner.Id, TrackId = mine.Id }, - new UserHasTrack { UserId = other.Id, TrackId = theirs.Id }); + var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - 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); + } - Assert.False(result.IsError); - var track = Assert.Single(result.Value.Items); - Assert.Equal("Mine", track.Title); - } + [Theory] + [InlineData(0, 1)] + [InlineData(-5, 1)] + public async Task Handle_NonPositivePageNumber_FallsBackToFirstPage(int requestedPageNumber, int expectedPageNumber) + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync(owner, track, new UserHasTrack { UserId = owner.Id, TrackId = track.Id }); - [Theory] - [InlineData(0, 1)] - [InlineData(-5, 1)] - public async Task Handle_NonPositivePageNumber_FallsBackToFirstPage(int requestedPageNumber, int expectedPageNumber) - { - var owner = TestEntities.User(); - var track = TestEntities.Track(owner); - await SeedAsync(owner, track, new UserHasTrack { UserId = owner.Id, TrackId = track.Id }); + var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: requestedPageNumber, PageSize: 10), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: requestedPageNumber, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(expectedPageNumber, result.Value.PageNumber); - } + Assert.False(result.IsError); + Assert.Equal(expectedPageNumber, result.Value.PageNumber); } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs index 90f828e..869ab14 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs @@ -3,64 +3,63 @@ using Musify.Application.Tracks; using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Application.Tests.Tracks +namespace Musify.Application.Tests.Tracks; + +public sealed class GetTracksQueryHandlerTests : HandlerTestBase { - public sealed class GetTracksQueryHandlerTests : HandlerTestBase + private GetTracksQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns() { - private GetTracksQueryHandler CreateHandler() => new(Database); + var owner = TestEntities.User(); + var trackA = TestEntities.Track(owner, "Alpha"); + var trackB = TestEntities.Track(owner, "Beta"); + await SeedAsync( + owner, trackA, trackB, + new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = trackA.Id }, + new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = trackB.Id }); - [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, - new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = trackA.Id }, - new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = trackB.Id }); + var result = await CreateHandler().Handle(new GetTracksQuery(Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetTracksQuery(Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(2, result.Value.TotalItemCount); + Assert.Equal(2, result.Value.Items.Count); + } - Assert.False(result.IsError); - Assert.Equal(2, result.Value.TotalItemCount); - Assert.Equal(2, result.Value.Items.Count); - } + [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, + new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = match.Id }, + new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = other.Id }); - [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, - new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = match.Id }, - new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = other.Id }); + var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + var item = Assert.Single(result.Value.Items); + Assert.Equal("Bohemian Rhapsody", item.Track.Title); + } - Assert.False(result.IsError); - var item = Assert.Single(result.Value.Items); - Assert.Equal("Bohemian Rhapsody", item.Track.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, + new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = rock.Id }, + new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = jazz.Id }); - [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, - new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = rock.Id }, - new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = jazz.Id }); + var result = await CreateHandler().Handle(new GetTracksQuery(null, 1, 10, Genre.Indie), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetTracksQuery(null, 1, 10, Genre.Indie), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - var item = Assert.Single(result.Value.Items); - Assert.Equal("Loud", item.Track.Title); - } + Assert.False(result.IsError); + var item = Assert.Single(result.Value.Items); + Assert.Equal("Loud", item.Track.Title); } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs index d8f4050..de49205 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs @@ -5,123 +5,122 @@ using Musify.Application.Tracks; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Tracks +namespace Musify.Application.Tests.Tracks; + +public sealed class RecordListeningProgressCommandHandlerTests : HandlerTestBase { - public sealed class RecordListeningProgressCommandHandlerTests : HandlerTestBase + 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) { - private RecordListeningProgressCommandHandler CreateHandler() => new(Database); + 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 async Task ReloadAsync(Guid id) => - await Database.ListeningHistories.AsNoTracking() - .SingleAsync(l => l.Id == id, TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_BelowThreshold_StoresSecondsWithoutCounting() + { + var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); - 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); - } + var result = await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_BelowThreshold_StoresSecondsWithoutCounting() - { - var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); + Assert.False(result.IsError); + var stored = await ReloadAsync(listen.Id); + Assert.Equal(10, stored.PlayedSeconds); + Assert.False(stored.IsCounted); + } - var result = await CreateHandler().Handle( - new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ReachesThirtySeconds_MarksAsCounted() + { + var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); - Assert.False(result.IsError); - var stored = await ReloadAsync(listen.Id); - Assert.Equal(10, stored.PlayedSeconds); - Assert.False(stored.IsCounted); - } + await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 30), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_ReachesThirtySeconds_MarksAsCounted() - { - var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); + Assert.True((await ReloadAsync(listen.Id)).IsCounted); + } - await CreateHandler().Handle( - new RecordListeningProgressCommand(user.Id, listen.Id, 30), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_ShortTrack_CountsAtHalfDuration() + { + var (user, listen) = await SeedListenAsync(20, DateTime.UtcNow.AddSeconds(-60)); - Assert.True((await ReloadAsync(listen.Id)).IsCounted); - } + await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_ShortTrack_CountsAtHalfDuration() - { - var (user, listen) = await SeedListenAsync(20, DateTime.UtcNow.AddSeconds(-60)); + Assert.True((await ReloadAsync(listen.Id)).IsCounted); + } - await CreateHandler().Handle( - new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_LowerValueThanStored_KeepsHighest() + { + var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); + var handler = CreateHandler(); - Assert.True((await ReloadAsync(listen.Id)).IsCounted); - } + 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); - [Fact] - public async Task Handle_LowerValueThanStored_KeepsHighest() - { - var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); - var handler = CreateHandler(); + var stored = await ReloadAsync(listen.Id); + Assert.Equal(40, stored.PlayedSeconds); + Assert.True(stored.IsCounted); + } - 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); + [Fact] + public async Task Handle_MoreThanElapsedClock_IsCappedToElapsedTime() + { + var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-20)); - var stored = await ReloadAsync(listen.Id); - Assert.Equal(40, stored.PlayedSeconds); - Assert.True(stored.IsCounted); - } + await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 150), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_MoreThanElapsedClock_IsCappedToElapsedTime() - { - var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-20)); + var stored = await ReloadAsync(listen.Id); + Assert.InRange(stored.PlayedSeconds!.Value, 24, 27); + } - await CreateHandler().Handle( - new RecordListeningProgressCommand(user.Id, listen.Id, 150), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_MoreThanTrackDuration_IsCappedToDuration() + { + var (user, listen) = await SeedListenAsync(50, DateTime.UtcNow.AddMinutes(-30)); - var stored = await ReloadAsync(listen.Id); - Assert.InRange(stored.PlayedSeconds!.Value, 24, 27); - } + await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 900), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_MoreThanTrackDuration_IsCappedToDuration() - { - var (user, listen) = await SeedListenAsync(50, DateTime.UtcNow.AddMinutes(-30)); + Assert.Equal(52, (await ReloadAsync(listen.Id)).PlayedSeconds); + } - await CreateHandler().Handle( - new RecordListeningProgressCommand(user.Id, listen.Id, 900), TestContext.Current.CancellationToken); + [Fact] + public async Task Handle_OtherUsersListen_ReturnsNotFound() + { + var (_, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); - Assert.Equal(52, (await ReloadAsync(listen.Id)).PlayedSeconds); - } + var result = await CreateHandler().Handle( + new RecordListeningProgressCommand(long.MaxValue, listen.Id, 10), TestContext.Current.CancellationToken); - [Fact] - public async Task Handle_OtherUsersListen_ReturnsNotFound() - { - var (_, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); + Assert.True(result.IsError); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } - var result = await CreateHandler().Handle( - new RecordListeningProgressCommand(long.MaxValue, listen.Id, 10), TestContext.Current.CancellationToken); + [Theory] + [InlineData(-1)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public async Task Handle_InvalidSeconds_ReturnsValidationError(double seconds) + { + var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); - Assert.True(result.IsError); - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + var result = await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, seconds), TestContext.Current.CancellationToken); - [Theory] - [InlineData(-1)] - [InlineData(double.NaN)] - [InlineData(double.PositiveInfinity)] - public async Task Handle_InvalidSeconds_ReturnsValidationError(double seconds) - { - var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); - - var result = await CreateHandler().Handle( - new RecordListeningProgressCommand(user.Id, listen.Id, seconds), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } + Assert.Equal(ErrorType.Validation, result.FirstError.Type); } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs index 8867b86..eab3b41 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/RequestTrackUploadUrlsCommandHandlerTests.cs @@ -9,73 +9,72 @@ using Musify.Domain.ValueObjects; using NSubstitute; using Xunit; -namespace Musify.Application.Tests.Tracks +namespace Musify.Application.Tests.Tracks; + +public sealed class RequestTrackUploadUrlsCommandHandlerTests : HandlerTestBase { - public sealed class RequestTrackUploadUrlsCommandHandlerTests : HandlerTestBase + private readonly IStorageService storageService = Substitute.For(); + + private RequestTrackUploadUrlsCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) { - private readonly IStorageService storageService = Substitute.For(); + storageService + .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("https://storage.musify.test/presigned-upload"); - private RequestTrackUploadUrlsCommandHandler CreateHandler(UploadIntentConfiguration? uploadIntentConfig = null) - { - storageService - .GetUploadUrlAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns("https://storage.musify.test/presigned-upload"); + return new RequestTrackUploadUrlsCommandHandler( + Database, + new UploadIntentValidator(Database, storageService), + storageService, + NoOpLogger(), + TestConfigurations.Storage(), + TestConfigurations.Track(), + uploadIntentConfig ?? TestConfigurations.UploadIntent()); + } - return new RequestTrackUploadUrlsCommandHandler( - Database, - new UploadIntentValidator(Database, storageService), - storageService, - NoOpLogger(), - TestConfigurations.Storage(), - TestConfigurations.Track(), - uploadIntentConfig ?? TestConfigurations.UploadIntent()); - } + [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/Users/CreateUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/CreateUserCommandHandlerTests.cs index eb15fa6..1dd4718 100644 --- a/backend/Tests/Musify.Application.Tests/Users/CreateUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/CreateUserCommandHandlerTests.cs @@ -3,39 +3,38 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Users; using Xunit; -namespace Musify.Application.Tests.Users +namespace Musify.Application.Tests.Users; + +public sealed class CreateUserCommandHandlerTests : HandlerTestBase { - public sealed class CreateUserCommandHandlerTests : HandlerTestBase + private CreateUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_NewId_CreatesUser() { - private CreateUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); + var command = new CreateUserCommand(1, "Jane Doe", "Jane", "Doe"); - [Fact] - public async Task Handle_NewId_CreatesUser() - { - var command = new CreateUserCommand(1, "Jane Doe", "Jane", "Doe"); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal("Jane Doe", result.Value.Name); + Assert.Equal(1, result.Value.Id); - Assert.False(result.IsError); - Assert.Equal("Jane Doe", result.Value.Name); - Assert.Equal(1, result.Value.Id); + var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("JANE DOE", stored.NormalizedName); + } - var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal("JANE DOE", stored.NormalizedName); - } + [Fact] + public async Task Handle_IdAlreadyExists_ReturnsConflict() + { + var existing = TestEntities.User(1); + await SeedAsync(existing); - [Fact] - public async Task Handle_IdAlreadyExists_ReturnsConflict() - { - var existing = TestEntities.User(1); - await SeedAsync(existing); + var command = new CreateUserCommand(1, "Someone Else", null, null); - var command = new CreateUserCommand(1, "Someone Else", null, null); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); - } + Assert.Equal(ErrorType.Conflict, result.FirstError.Type); } } diff --git a/backend/Tests/Musify.Application.Tests/Users/FollowUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/FollowUserCommandHandlerTests.cs index 3659bbf..3a17c25 100644 --- a/backend/Tests/Musify.Application.Tests/Users/FollowUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/FollowUserCommandHandlerTests.cs @@ -4,58 +4,57 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Users; using Xunit; -namespace Musify.Application.Tests.Users +namespace Musify.Application.Tests.Users; + +public sealed class FollowUserCommandHandlerTests : HandlerTestBase { - public sealed class FollowUserCommandHandlerTests : HandlerTestBase + private FollowUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_ValidTarget_CreatesFollow() { - private FollowUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed); - [Fact] - public async Task Handle_ValidTarget_CreatesFollow() - { - var follower = TestEntities.User(1, "follower"); - var followed = TestEntities.User(2, "followed"); - await SeedAsync(follower, followed); + var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Single(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken)); + } - Assert.False(result.IsError); - Assert.Single(await Database.UserFollows.ToListAsync(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 }); - [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 }); + var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Single(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken)); + } - Assert.False(result.IsError); - Assert.Single(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken)); - } + [Fact] + public async Task Handle_SelfFollow_ReturnsValidationError() + { + var user = TestEntities.User(); + await SeedAsync(user); - [Fact] - public async Task Handle_SelfFollow_ReturnsValidationError() - { - var user = TestEntities.User(); - await SeedAsync(user); + var result = await CreateHandler().Handle(new FollowUserCommand(user.Id, user.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new FollowUserCommand(user.Id, user.Id), TestContext.Current.CancellationToken); + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } - Assert.Equal(ErrorType.Validation, result.FirstError.Type); - } + [Fact] + public async Task Handle_TargetMissing_ReturnsNotFound() + { + var follower = TestEntities.User(); + await SeedAsync(follower); - [Fact] - public async Task Handle_TargetMissing_ReturnsNotFound() - { - var follower = TestEntities.User(); - await SeedAsync(follower); + var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, 404), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, 404), TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.NotFound, result.FirstError.Type); - } + 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 c27d93f..ebca2ff 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs @@ -2,117 +2,116 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Users; using Xunit; -namespace Musify.Application.Tests.Users +namespace Musify.Application.Tests.Users; + +public sealed class GetListeningStatsQueryHandlerTests : HandlerTestBase { - public sealed class GetListeningStatsQueryHandlerTests : HandlerTestBase + private GetListeningStatsQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_NoHistory_ReturnsZeroes() { - private GetListeningStatsQueryHandler CreateHandler() => new(Database); + var user = TestEntities.User(); + await SeedAsync(user); - [Fact] - public async Task Handle_NoHistory_ReturnsZeroes() - { - var user = TestEntities.User(); - await SeedAsync(user); + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + Assert.Equal(0, result.TracksThisWeek); + Assert.Equal(0, result.SecondsThisWeek); + Assert.Equal(0, result.StreakDays); + } - Assert.Equal(0, result.TracksThisWeek); - Assert.Equal(0, result.SecondsThisWeek); - Assert.Equal(0, result.StreakDays); - } + [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))); - [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))); + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + Assert.Equal(2, result.TracksThisWeek); + Assert.Equal(500, result.SecondsThisWeek); + } - Assert.Equal(2, result.TracksThisWeek); - Assert.Equal(500, result.SecondsThisWeek); - } + [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)); - [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)); + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + Assert.Equal(212, result.SecondsThisWeek); + } - Assert.Equal(212, result.SecondsThisWeek); - } + [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)); - [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)); + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + Assert.Equal(0, result.TracksThisWeek); + Assert.Equal(0, result.StreakDays); + Assert.Equal(3, result.SecondsThisWeek); + } - Assert.Equal(0, result.TracksThisWeek); - Assert.Equal(0, result.StreakDays); - Assert.Equal(3, result.SecondsThisWeek); - } + [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))); - [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))); + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + Assert.Equal(2, result.StreakDays); + } - Assert.Equal(2, result.StreakDays); - } + [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))); - [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))); + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + Assert.Equal(1, result.StreakDays); + } - Assert.Equal(1, result.StreakDays); - } + [Fact] + public async Task Handle_NoListenToday_StreakIsZero() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-1))); - [Fact] - public async Task Handle_NoListenToday_StreakIsZero() - { - var user = TestEntities.User(); - var track = TestEntities.Track(user); - await SeedAsync( - user, track, - TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-1))); + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); - - Assert.Equal(0, result.StreakDays); - } + 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 1854be6..2bcb0ac 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetUserByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetUserByIdQueryHandlerTests.cs @@ -3,30 +3,29 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Users; using Xunit; -namespace Musify.Application.Tests.Users +namespace Musify.Application.Tests.Users; + +public sealed class GetUserByIdQueryHandlerTests : HandlerTestBase { - public sealed class GetUserByIdQueryHandlerTests : HandlerTestBase + private GetUserByIdQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_ExistingUser_ReturnsIt() { - private GetUserByIdQueryHandler CreateHandler() => new(Database); + var user = TestEntities.User(1, "Jane Doe"); + await SeedAsync(user); - [Fact] - public async Task Handle_ExistingUser_ReturnsIt() - { - var user = TestEntities.User(1, "Jane Doe"); - await SeedAsync(user); + var result = await CreateHandler().Handle(new GetUserByIdQuery(1), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetUserByIdQuery(1), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal("Jane Doe", result.Value.Name); + } - Assert.False(result.IsError); - Assert.Equal("Jane Doe", result.Value.Name); - } + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetUserByIdQuery(404), TestContext.Current.CancellationToken); - [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); - } + 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 825e7ff..4778b07 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetUserFollowersQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetUserFollowersQueryHandlerTests.cs @@ -6,90 +6,89 @@ using Musify.Application.Users.Responses; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Users +namespace Musify.Application.Tests.Users; + +public sealed class GetUserFollowersQueryHandlerTests : HandlerTestBase { - public sealed class GetUserFollowersQueryHandlerTests : HandlerTestBase + 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() { - private GetUserFollowersQueryHandler CreateHandler() => new(Database); + 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 async Task>> Send(long? viewerId, long userId) => - await CreateHandler().Handle(new GetUserFollowersQuery(viewerId, userId, 1, 20), TestContext.Current.CancellationToken); + var result = await Send(user.Id, user.Id); - [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 }); + Assert.False(result.IsError); + var item = Assert.Single(result.Value.Items); + Assert.Equal(follower.Id, item.Id); + Assert.False(item.IsFollowedByViewer); + } - var result = await Send(user.Id, 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 }); - Assert.False(result.IsError); - var item = Assert.Single(result.Value.Items); - Assert.Equal(follower.Id, item.Id); - Assert.False(item.IsFollowedByViewer); - } + var result = await Send(viewer.Id, 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 }); + Assert.False(result.IsError); + Assert.Equal(2, result.Value.TotalItemCount); + Assert.All(result.Value.Items, item => Assert.Equal(item.Id == other.Id, item.IsFollowedByViewer)); + } - var result = await Send(viewer.Id, user.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 }); - Assert.False(result.IsError); - Assert.Equal(2, result.Value.TotalItemCount); - Assert.All(result.Value.Items, item => Assert.Equal(item.Id == other.Id, item.IsFollowedByViewer)); - } + var result = await Send(viewer.Id, user.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 }); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } - var result = await Send(viewer.Id, 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 }); - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } + var result = await Send(viewer.Id, 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 }); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } - var result = await Send(viewer.Id, user.Id); + [Fact] + public async Task Handle_Anonymous_ReturnsForbidden() + { + await SeedAsync(TestEntities.User(1, "user")); - Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); - } + var result = await Send(null, 1); - [Fact] - public async Task Handle_Anonymous_ReturnsForbidden() - { - await SeedAsync(TestEntities.User(1, "user")); + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } - var result = await Send(null, 1); + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var result = await Send(1, 404); - 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); - } + 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 c074d00..580ebd5 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetUserFollowingQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetUserFollowingQueryHandlerTests.cs @@ -4,72 +4,71 @@ using Musify.Application.Users; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Users +namespace Musify.Application.Tests.Users; + +public sealed class GetUserFollowingQueryHandlerTests : HandlerTestBase { - public sealed class GetUserFollowingQueryHandlerTests : HandlerTestBase + private GetUserFollowingQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_Anonymous_ReturnsFollowedUsers() { - private GetUserFollowingQueryHandler CreateHandler() => new(Database); + 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 }); - [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 }); + var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, user.Id, 1, 20), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, user.Id, 1, 20), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(2, result.Value.TotalItemCount); + Assert.All(result.Value.Items, item => Assert.False(item.IsFollowedByViewer)); + } - Assert.False(result.IsError); - Assert.Equal(2, result.Value.TotalItemCount); - Assert.All(result.Value.Items, item => Assert.False(item.IsFollowedByViewer)); - } + [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 }); - [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 }); + var result = await CreateHandler().Handle(new GetUserFollowingQuery(viewer.Id, user.Id, 1, 20), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetUserFollowingQuery(viewer.Id, user.Id, 1, 20), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.All(result.Value.Items, item => Assert.Equal(item.Id == followedA.Id, item.IsFollowedByViewer)); + } - Assert.False(result.IsError); - Assert.All(result.Value.Items, item => Assert.Equal(item.Id == followedA.Id, item.IsFollowedByViewer)); - } + [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 }); - [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 }); + var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 1, 2, 2), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 1, 2, 2), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Single(result.Value.Items); + Assert.Equal(3, result.Value.TotalItemCount); + Assert.False(result.Value.HasNextPage); + } - Assert.False(result.IsError); - Assert.Single(result.Value.Items); - Assert.Equal(3, result.Value.TotalItemCount); - Assert.False(result.Value.HasNextPage); - } + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 404, 1, 20), 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.Equal(ErrorType.NotFound, result.FirstError.Type); - } + 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 45665aa..e7e40d8 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetUserProfileQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetUserProfileQueryHandlerTests.cs @@ -4,78 +4,77 @@ using Musify.Application.Users; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Users +namespace Musify.Application.Tests.Users; + +public sealed class GetUserProfileQueryHandlerTests : HandlerTestBase { - public sealed class GetUserProfileQueryHandlerTests : HandlerTestBase + private GetUserProfileQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_ExistingUser_ReturnsFollowerAndFollowingCounts() { - private GetUserProfileQueryHandler CreateHandler() => new(Database); + 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 }); - [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 }); + var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(2, result.Value.FollowersCount); + Assert.Equal(1, result.Value.FollowingCount); + } - Assert.False(result.IsError); - Assert.Equal(2, result.Value.FollowersCount); - Assert.Equal(1, result.Value.FollowingCount); - } + [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 }); - [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 }); + var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken); + Assert.True(result.Value.IsFollowing); + Assert.True(result.Value.CanViewFollowers); + } - Assert.True(result.Value.IsFollowing); - Assert.True(result.Value.CanViewFollowers); - } + [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 }); - [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 }); + var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken); + Assert.True(result.Value.IsFollowing); + Assert.False(result.Value.CanViewFollowers); + } - Assert.True(result.Value.IsFollowing); - Assert.False(result.Value.CanViewFollowers); - } + [Fact] + public async Task Handle_OwnProfile_CanSeeFollowersButIsNotFollowing() + { + await SeedAsync(TestEntities.User(1, "user")); - [Fact] - public async Task Handle_OwnProfile_CanSeeFollowersButIsNotFollowing() - { - await SeedAsync(TestEntities.User(1, "user")); + var result = await CreateHandler().Handle(new GetUserProfileQuery(1, 1), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetUserProfileQuery(1, 1), TestContext.Current.CancellationToken); + Assert.False(result.Value.IsFollowing); + Assert.True(result.Value.CanViewFollowers); + } - Assert.False(result.Value.IsFollowing); - Assert.True(result.Value.CanViewFollowers); - } + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetUserProfileQuery(404), TestContext.Current.CancellationToken); - [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); - } + 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 a57cfb4..fe1dc64 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs @@ -3,46 +3,45 @@ using Musify.Application.Users; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Users +namespace Musify.Application.Tests.Users; + +public sealed class GetUsersQueryHandlerTests : HandlerTestBase { - public sealed class GetUsersQueryHandlerTests : HandlerTestBase + private GetUsersQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_NoSearch_ReturnsEveryUser() { - private GetUsersQueryHandler CreateHandler() => new(Database); + await SeedAsync(TestEntities.User(1, "alice"), TestEntities.User(2, "bob")); - [Fact] - public async Task Handle_NoSearch_ReturnsEveryUser() - { - await SeedAsync(TestEntities.User(1, "alice"), TestEntities.User(2, "bob")); + var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Equal(2, result.Value.TotalItemCount); + } - Assert.False(result.IsError); - Assert.Equal(2, result.Value.TotalItemCount); - } + [Fact] + public async Task Handle_UsernameSearch_ReturnsOnlyMatches() + { + await SeedAsync(TestEntities.User(1, "alice-wonderland"), TestEntities.User(2, "bob-builder")); - [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: "alice"), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: "alice"), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + var user = Assert.Single(result.Value.Items); + Assert.Equal(1, user.Id); + } - Assert.False(result.IsError); - var user = Assert.Single(result.Value.Items); - Assert.Equal(1, user.Id); - } + [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 }); - [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); - var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null, ViewerId: 1), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.All(result.Value.Items, item => Assert.Equal(item.Id == 2, item.IsFollowedByViewer)); - } + Assert.False(result.IsError); + Assert.All(result.Value.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 2d7f9ea..48650e0 100644 --- a/backend/Tests/Musify.Application.Tests/Users/IsFollowingUserQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/IsFollowingUserQueryHandlerTests.cs @@ -3,34 +3,33 @@ using Musify.Application.Users; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Users +namespace Musify.Application.Tests.Users; + +public sealed class IsFollowingUserQueryHandlerTests : HandlerTestBase { - public sealed class IsFollowingUserQueryHandlerTests : HandlerTestBase + private IsFollowingUserQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_Following_ReturnsTrue() { - private IsFollowingUserQueryHandler CreateHandler() => new(Database); + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed, new UserFollow { FollowerId = follower.Id, FollowedId = followed.Id }); - [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 }); + var result = await CreateHandler().Handle(new IsFollowingUserQuery(follower.Id, followed.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new IsFollowingUserQuery(follower.Id, followed.Id), TestContext.Current.CancellationToken); + Assert.True(result); + } - Assert.True(result); - } + [Fact] + public async Task Handle_NotFollowing_ReturnsFalse() + { + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed); - [Fact] - public async Task Handle_NotFollowing_ReturnsFalse() - { - var follower = TestEntities.User(1, "follower"); - var followed = TestEntities.User(2, "followed"); - await SeedAsync(follower, followed); + var result = await CreateHandler().Handle(new IsFollowingUserQuery(follower.Id, followed.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new IsFollowingUserQuery(follower.Id, followed.Id), TestContext.Current.CancellationToken); - - Assert.False(result); - } + Assert.False(result); } } diff --git a/backend/Tests/Musify.Application.Tests/Users/SyncUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/SyncUserCommandHandlerTests.cs index 3702165..4a73c0f 100644 --- a/backend/Tests/Musify.Application.Tests/Users/SyncUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/SyncUserCommandHandlerTests.cs @@ -2,42 +2,41 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Users; using Xunit; -namespace Musify.Application.Tests.Users +namespace Musify.Application.Tests.Users; + +public sealed class SyncUserCommandHandlerTests : HandlerTestBase { - public sealed class SyncUserCommandHandlerTests : HandlerTestBase + private SyncUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_UnknownUser_ProvisionsIt() { - private SyncUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); + var command = new SyncUserCommand(1, "Jane Doe", "Jane", "Doe", "https://avatar.example/jane.png"); - [Fact] - public async Task Handle_UnknownUser_ProvisionsIt() - { - var command = new SyncUserCommand(1, "Jane Doe", "Jane", "Doe", "https://avatar.example/jane.png"); + 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.Users.FindAsync([1L], TestContext.Current.CancellationToken); + Assert.NotNull(stored); + Assert.Equal("Jane Doe", stored.Name); + Assert.Equal("https://avatar.example/jane.png", stored.ProfilePictureUrl); + } - 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); - } + [Fact] + public async Task Handle_ExistingUser_UpdatesProfileFields() + { + var user = TestEntities.User(1, "Old Name", profilePictureUrl: null); + await SeedAsync(user); - [Fact] - public async Task Handle_ExistingUser_UpdatesProfileFields() - { - var user = TestEntities.User(1, "Old Name", profilePictureUrl: null); - await SeedAsync(user); + var command = new SyncUserCommand(1, "New Name", "New", "Name", "https://avatar.example/new.png"); - var command = new SyncUserCommand(1, "New Name", "New", "Name", "https://avatar.example/new.png"); + 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.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); - } + 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 e853dd8..7d24067 100644 --- a/backend/Tests/Musify.Application.Tests/Users/UnfollowUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/UnfollowUserCommandHandlerTests.cs @@ -4,35 +4,34 @@ using Musify.Application.Users; using Musify.Domain.Entities; using Xunit; -namespace Musify.Application.Tests.Users +namespace Musify.Application.Tests.Users; + +public sealed class UnfollowUserCommandHandlerTests : HandlerTestBase { - public sealed class UnfollowUserCommandHandlerTests : HandlerTestBase + private UnfollowUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_ExistingFollow_RemovesIt() { - private UnfollowUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); + var follower = TestEntities.User(1, "follower"); + var followed = TestEntities.User(2, "followed"); + await SeedAsync(follower, followed, new UserFollow { FollowerId = follower.Id, FollowedId = followed.Id }); - [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 }); + var result = await CreateHandler().Handle(new UnfollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new UnfollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); + Assert.False(result.IsError); + Assert.Empty(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken)); + } - Assert.False(result.IsError); - Assert.Empty(await Database.UserFollows.ToListAsync(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); - [Fact] - public async Task Handle_NotFollowing_IsIdempotent() - { - var follower = TestEntities.User(1, "follower"); - var followed = TestEntities.User(2, "followed"); - await SeedAsync(follower, followed); + var result = await CreateHandler().Handle(new UnfollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); - var result = await CreateHandler().Handle(new UnfollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - } + 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 1aa3589..56784fc 100644 --- a/backend/Tests/Musify.Domain.Tests/ValueObjects/GenreCompatibilityTests.cs +++ b/backend/Tests/Musify.Domain.Tests/ValueObjects/GenreCompatibilityTests.cs @@ -1,51 +1,50 @@ using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Domain.Tests.ValueObjects +namespace Musify.Domain.Tests.ValueObjects; + +public sealed class GenreCompatibilityTests { - public sealed class GenreCompatibilityTests + [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) { - [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)); - } + 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 78fde26..7b4e0a5 100644 --- a/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackAudioTests.cs +++ b/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackAudioTests.cs @@ -1,23 +1,22 @@ using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Domain.Tests.ValueObjects -{ - public sealed class TrackAudioTests - { - [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 }; +namespace Musify.Domain.Tests.ValueObjects; - Assert.Equal(expectedProcessed, audio.IsProcessed); - Assert.Equal(expectedFailed, audio.IsFailed); - Assert.Equal(expectedInProgress, audio.IsInProgress); - } +public sealed class TrackAudioTests +{ + [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); } } diff --git a/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackPicturesTests.cs b/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackPicturesTests.cs index 9ac53db..a9b0ca3 100644 --- a/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackPicturesTests.cs +++ b/backend/Tests/Musify.Domain.Tests/ValueObjects/TrackPicturesTests.cs @@ -1,20 +1,19 @@ using Musify.Domain.ValueObjects; using Xunit; -namespace Musify.Domain.Tests.ValueObjects -{ - public sealed class TrackPicturesTests - { - [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 }; +namespace Musify.Domain.Tests.ValueObjects; - Assert.Equal(expected, pictures.IsProcessed); - } +public sealed class TrackPicturesTests +{ + [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); } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/Persistence/AuditableEntityInterceptorTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Persistence/AuditableEntityInterceptorTests.cs index db7ce42..a846be2 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Persistence/AuditableEntityInterceptorTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Persistence/AuditableEntityInterceptorTests.cs @@ -3,52 +3,51 @@ using Musify.Domain.Entities; using Musify.Infrastructure.Tests.TestSupport; using Xunit; -namespace Musify.Infrastructure.Tests.Persistence +namespace Musify.Infrastructure.Tests.Persistence; + +[Collection(InfrastructureCollection.Name)] +public sealed class AuditableEntityInterceptorTests(InfrastructureTestFixture fixture) { - [Collection(InfrastructureCollection.Name)] - public sealed class AuditableEntityInterceptorTests(InfrastructureTestFixture fixture) + [Fact] + public async Task SavingChanges_NewEntity_SetsCreatedAtAndUpdatedAtToNow() { - [Fact] - public async Task SavingChanges_NewEntity_SetsCreatedAtAndUpdatedAtToNow() - { - await using var database = fixture.CreateDatabase(); - var before = DateTime.UtcNow; + 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 3265b9e..57c7c26 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs @@ -4,105 +4,104 @@ using Musify.Domain.ValueObjects; using Musify.Infrastructure.Tests.TestSupport; using Xunit; -namespace Musify.Infrastructure.Tests.Persistence +namespace Musify.Infrastructure.Tests.Persistence; + +[Collection(InfrastructureCollection.Name)] +public sealed class DatabaseTests(InfrastructureTestFixture fixture) { - [Collection(InfrastructureCollection.Name)] - public sealed class DatabaseTests(InfrastructureTestFixture fixture) + [Fact] + public async Task SaveChangesAsync_TrackWithOwnedTypes_RoundTripsThroughPostgres() { - [Fact] - public async Task SaveChangesAsync_TrackWithOwnedTypes_RoundTripsThroughPostgres() + 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 { - await using var database = fixture.CreateDatabase(); + 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); - 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); + await using var reloaded = fixture.CreateDatabase(); + var stored = await reloaded.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken); - 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); + 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 using var reloaded = fixture.CreateDatabase(); - var stored = await reloaded.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken); + [Fact] + public async Task SaveChangesAsync_TrackWithTags_RoundTripsThroughNativePostgresEnum() + { + await using var database = fixture.CreateDatabase(); - 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 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 } + ]; - [Fact] - public async Task SaveChangesAsync_TrackWithTags_RoundTripsThroughNativePostgresEnum() + 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 { - 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() - }; - } + 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 7b0f086..bf893c8 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/PictureServiceTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/PictureServiceTests.cs @@ -4,55 +4,54 @@ using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using Xunit; -namespace Musify.Infrastructure.Tests.Services +namespace Musify.Infrastructure.Tests.Services; + +public sealed class PictureServiceTests { - public sealed class PictureServiceTests + private static PictureService CreateService() => new(NullLogger.Instance); + + private static MemoryStream CreateSourceImage(int width, int height) { - private static PictureService CreateService() => new(NullLogger.Instance); + 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 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; - } + [Fact] + public async Task ResizePictureAsWebpAsync_LargerThanTarget_ShrinksToFitWithinTheRequestedBox() + { + using var source = CreateSourceImage(800, 600); - [Fact] - public async Task ResizePictureAsWebpAsync_LargerThanTarget_ShrinksToFitWithinTheRequestedBox() - { - using var source = CreateSourceImage(800, 600); + await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 200, 200, TestContext.Current.CancellationToken); - await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 200, 200, TestContext.Current.CancellationToken); + 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"); + } - 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"); - } + [Fact] + public async Task ResizePictureAsWebpAsync_ReturnsAValidWebpStream() + { + using var source = CreateSourceImage(100, 100); - [Fact] - public async Task ResizePictureAsWebpAsync_ReturnsAValidWebpStream() - { - using var source = CreateSourceImage(100, 100); + await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 50, 50, TestContext.Current.CancellationToken); - await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 50, 50, TestContext.Current.CancellationToken); + var format = await Image.DetectFormatAsync(resized, TestContext.Current.CancellationToken); + Assert.Equal("WEBP", format?.Name, ignoreCase: true); + } - var format = await Image.DetectFormatAsync(resized, TestContext.Current.CancellationToken); - Assert.Equal("WEBP", format?.Name, ignoreCase: true); - } + [Fact] + public async Task ResizePictureAsWebpAsync_PreservesAspectRatio() + { + using var source = CreateSourceImage(400, 200); - [Fact] - public async Task ResizePictureAsWebpAsync_PreservesAspectRatio() - { - using var source = CreateSourceImage(400, 200); + await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 100, 100, TestContext.Current.CancellationToken); - 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); - } + 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/SingleFlightCacheTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/SingleFlightCacheTests.cs index 50d7158..da41272 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/SingleFlightCacheTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/SingleFlightCacheTests.cs @@ -3,79 +3,78 @@ using Microsoft.Extensions.Caching.Memory; using Musify.Infrastructure.Services; using Xunit; -namespace Musify.Infrastructure.Tests.Services +namespace Musify.Infrastructure.Tests.Services; + +public sealed class SingleFlightCacheTests { - public sealed class SingleFlightCacheTests + private static SingleFlightCache CreateCache() => new(new MemoryCache(new MemoryCacheOptions())); + + [Fact] + public async Task GetOrCreateAsync_SecondCallWithinDuration_ReturnsCachedValueWithoutCallingFactory() { - private static SingleFlightCache CreateCache() => new(new MemoryCache(new MemoryCacheOptions())); + var cache = CreateCache(); + var calls = 0; + Task> Factory() { calls++; return Task.FromResult>(calls); } - [Fact] - public async Task GetOrCreateAsync_SecondCallWithinDuration_ReturnsCachedValueWithoutCallingFactory() + var first = await cache.GetOrCreateAsync("key", TimeSpan.FromMinutes(1), Factory); + var second = await cache.GetOrCreateAsync("key", TimeSpan.FromMinutes(1), Factory); + + Assert.Equal(1, first.Value); + Assert.Equal(1, second.Value); + Assert.Equal(1, calls); + } + + [Fact] + public async Task GetOrCreateAsync_ConcurrentCallsForTheSameKey_CollapseIntoASingleFactoryCall() + { + var cache = CreateCache(); + var calls = 0; + var gate = new TaskCompletionSource(); + + async Task> Factory() { - var cache = CreateCache(); - var calls = 0; - Task> Factory() { calls++; return Task.FromResult>(calls); } - - var first = await cache.GetOrCreateAsync("key", TimeSpan.FromMinutes(1), Factory); - var second = await cache.GetOrCreateAsync("key", TimeSpan.FromMinutes(1), Factory); - - Assert.Equal(1, first.Value); - Assert.Equal(1, second.Value); - Assert.Equal(1, calls); + Interlocked.Increment(ref calls); + await gate.Task; + return 7; } - [Fact] - public async Task GetOrCreateAsync_ConcurrentCallsForTheSameKey_CollapseIntoASingleFactoryCall() + var first = cache.GetOrCreateAsync("concurrent-key", TimeSpan.FromMinutes(1), Factory); + var second = cache.GetOrCreateAsync("concurrent-key", TimeSpan.FromMinutes(1), Factory); + gate.SetResult(); + + var results = await Task.WhenAll(first, second); + + Assert.Equal(1, calls); + Assert.All(results, result => Assert.Equal(7, result.Value)); + } + + [Fact] + public async Task GetOrCreateAsync_FactoryReturnsError_DoesNotCacheIt() + { + var cache = CreateCache(); + var calls = 0; + Task> Factory() { - var cache = CreateCache(); - var calls = 0; - var gate = new TaskCompletionSource(); - - async Task> Factory() - { - Interlocked.Increment(ref calls); - await gate.Task; - return 7; - } - - var first = cache.GetOrCreateAsync("concurrent-key", TimeSpan.FromMinutes(1), Factory); - var second = cache.GetOrCreateAsync("concurrent-key", TimeSpan.FromMinutes(1), Factory); - gate.SetResult(); - - var results = await Task.WhenAll(first, second); - - Assert.Equal(1, calls); - Assert.All(results, result => Assert.Equal(7, result.Value)); + calls++; + return Task.FromResult>(Error.Failure(description: "boom")); } - [Fact] - public async Task GetOrCreateAsync_FactoryReturnsError_DoesNotCacheIt() - { - var cache = CreateCache(); - var calls = 0; - Task> Factory() - { - calls++; - return Task.FromResult>(Error.Failure(description: "boom")); - } + await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory); + await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory); - await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory); - await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory); + Assert.Equal(2, calls); + } - Assert.Equal(2, calls); - } + [Fact] + public async Task GetOrCreateAsync_NonPositiveDuration_AlwaysCallsTheFactory() + { + var cache = CreateCache(); + var calls = 0; + Task> Factory() { calls++; return Task.FromResult>(calls); } - [Fact] - public async Task GetOrCreateAsync_NonPositiveDuration_AlwaysCallsTheFactory() - { - var cache = CreateCache(); - var calls = 0; - Task> Factory() { calls++; return Task.FromResult>(calls); } + await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory); + await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory); - await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory); - await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory); - - Assert.Equal(2, calls); - } + Assert.Equal(2, calls); } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs index d2420f0..bee7c06 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs @@ -8,139 +8,138 @@ using Musify.Infrastructure.Services; using Musify.Infrastructure.Tests.TestSupport; using Xunit; -namespace Musify.Infrastructure.Tests.Services +namespace Musify.Infrastructure.Tests.Services; + +[Collection(InfrastructureCollection.Name)] +public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAsyncLifetime { - [Collection(InfrastructureCollection.Name)] - public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAsyncLifetime + private Database database = null!; + private StorageService service = null!; + + public ValueTask InitializeAsync() { - private Database database = null!; - private StorageService service = null!; - - public ValueTask InitializeAsync() + database = fixture.CreateDatabase(); + var storageConfiguration = new InfrastructureStorageConfiguration { - database = fixture.CreateDatabase(); - var storageConfiguration = new InfrastructureStorageConfiguration - { - Address = fixture.S3ServiceUrl, - AccessKey = InfrastructureTestFixture.S3AccessKey, - SecretAccessKey = InfrastructureTestFixture.S3SecretKey, - ForcePathStyle = true, - UseHttp = true, - }; - service = new StorageService(database, fixture.CreateS3Client(), NullLogger.Instance, storageConfiguration); - return ValueTask.CompletedTask; - } + Address = fixture.S3ServiceUrl, + AccessKey = InfrastructureTestFixture.S3AccessKey, + SecretAccessKey = InfrastructureTestFixture.S3SecretKey, + ForcePathStyle = true, + UseHttp = true, + }; + service = new StorageService(database, fixture.CreateS3Client(), NullLogger.Instance, storageConfiguration); + return ValueTask.CompletedTask; + } - public ValueTask DisposeAsync() => database.DisposeAsync(); + public ValueTask DisposeAsync() => database.DisposeAsync(); - [Fact] - public async Task UploadThenGetFile_RoundTripsTheSameBytes() + [Fact] + public async Task UploadThenGetFile_RoundTripsTheSameBytes() + { + var key = $"tests/{Guid.NewGuid():N}/round-trip.txt"; + var content = "hello seaweedfs"u8.ToArray(); + + await service.UploadFileAsync(new MemoryStream(content), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); + + await using var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); + using var reader = new StreamReader(stream, Encoding.UTF8); + var readBack = await reader.ReadToEndAsync(TestContext.Current.CancellationToken); + + Assert.Equal("hello seaweedfs", readBack); + } + + [Fact] + public async Task HeadObjectAsync_ExistingObject_ReturnsMetadata() + { + var key = $"tests/{Guid.NewGuid():N}/head.txt"; + await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); + + var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); + + Assert.NotNull(metadata); + Assert.Equal(1, metadata.ContentLength); + } + + [Fact] + public async Task HeadObjectAsync_MissingObject_ReturnsNull() + { + var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, $"tests/{Guid.NewGuid():N}/missing.txt", TestContext.Current.CancellationToken); + + Assert.Null(metadata); + } + + [Fact] + public async Task RemoveFileAsync_ExistingObject_DeletesIt() + { + var key = $"tests/{Guid.NewGuid():N}/to-remove.txt"; + await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); + + await service.RemoveFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); + + Assert.Null(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task CopyFileAsync_CopiesToTheDestinationKey() + { + var sourceKey = $"tests/{Guid.NewGuid():N}/source.txt"; + var destinationKey = $"tests/{Guid.NewGuid():N}/destination.txt"; + await service.UploadFileAsync(new MemoryStream("copy-me"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, sourceKey, TestContext.Current.CancellationToken); + + await service.CopyFileAsync(InfrastructureTestFixture.S3Bucket, sourceKey, InfrastructureTestFixture.S3Bucket, destinationKey, TestContext.Current.CancellationToken); + + Assert.NotNull(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, destinationKey, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ListObjectsAsync_ReturnsEveryObjectUnderThePrefix() + { + var prefix = $"tests/{Guid.NewGuid():N}"; + await service.UploadFileAsync(new MemoryStream("a"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/a.txt", TestContext.Current.CancellationToken); + await service.UploadFileAsync(new MemoryStream("b"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/b.txt", TestContext.Current.CancellationToken); + + var keys = new List(); + await foreach (var item in service.ListObjectsAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken)) + keys.Add(item.Key); + + Assert.Equal([$"{prefix}/a.txt", $"{prefix}/b.txt"], keys.Order()); + } + + [Fact] + public async Task RemoveFolderAsync_DeletesEveryObjectUnderThePrefix() + { + var prefix = $"tests/{Guid.NewGuid():N}"; + await service.UploadFileAsync(new MemoryStream("a"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/a.txt", TestContext.Current.CancellationToken); + await service.UploadFileAsync(new MemoryStream("b"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/nested/b.txt", TestContext.Current.CancellationToken); + + await service.RemoveFolderAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken); + + var remaining = new List(); + await foreach (var item in service.ListObjectsAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken)) + remaining.Add(item.Key); + Assert.Empty(remaining); + } + + [Fact] + public async Task GetUploadUrlAsync_PresignedPut_CanActuallyUploadTheObject() + { + var key = $"tests/{Guid.NewGuid():N}/presigned.txt"; + + var uploadUrl = await service.GetUploadUrlAsync( + InfrastructureTestFixture.S3Bucket, key, "text/plain", TimeSpan.FromMinutes(5), TestContext.Current.CancellationToken); + + using var httpClient = new HttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl) { - var key = $"tests/{Guid.NewGuid():N}/round-trip.txt"; - var content = "hello seaweedfs"u8.ToArray(); + 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); - await service.UploadFileAsync(new MemoryStream(content), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); - - await using var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); - using var reader = new StreamReader(stream, Encoding.UTF8); - var readBack = await reader.ReadToEndAsync(TestContext.Current.CancellationToken); - - Assert.Equal("hello seaweedfs", readBack); - } - - [Fact] - public async Task HeadObjectAsync_ExistingObject_ReturnsMetadata() - { - var key = $"tests/{Guid.NewGuid():N}/head.txt"; - await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); - - var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); - - Assert.NotNull(metadata); - Assert.Equal(1, metadata.ContentLength); - } - - [Fact] - public async Task HeadObjectAsync_MissingObject_ReturnsNull() - { - var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, $"tests/{Guid.NewGuid():N}/missing.txt", TestContext.Current.CancellationToken); - - Assert.Null(metadata); - } - - [Fact] - public async Task RemoveFileAsync_ExistingObject_DeletesIt() - { - var key = $"tests/{Guid.NewGuid():N}/to-remove.txt"; - await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); - - await service.RemoveFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken); - - Assert.Null(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task CopyFileAsync_CopiesToTheDestinationKey() - { - var sourceKey = $"tests/{Guid.NewGuid():N}/source.txt"; - var destinationKey = $"tests/{Guid.NewGuid():N}/destination.txt"; - await service.UploadFileAsync(new MemoryStream("copy-me"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, sourceKey, TestContext.Current.CancellationToken); - - await service.CopyFileAsync(InfrastructureTestFixture.S3Bucket, sourceKey, InfrastructureTestFixture.S3Bucket, destinationKey, TestContext.Current.CancellationToken); - - Assert.NotNull(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, destinationKey, TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task ListObjectsAsync_ReturnsEveryObjectUnderThePrefix() - { - var prefix = $"tests/{Guid.NewGuid():N}"; - await service.UploadFileAsync(new MemoryStream("a"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/a.txt", TestContext.Current.CancellationToken); - await service.UploadFileAsync(new MemoryStream("b"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/b.txt", TestContext.Current.CancellationToken); - - var keys = new List(); - await foreach (var item in service.ListObjectsAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken)) - keys.Add(item.Key); - - Assert.Equal([$"{prefix}/a.txt", $"{prefix}/b.txt"], keys.Order()); - } - - [Fact] - public async Task RemoveFolderAsync_DeletesEveryObjectUnderThePrefix() - { - var prefix = $"tests/{Guid.NewGuid():N}"; - await service.UploadFileAsync(new MemoryStream("a"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/a.txt", TestContext.Current.CancellationToken); - await service.UploadFileAsync(new MemoryStream("b"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/nested/b.txt", TestContext.Current.CancellationToken); - - await service.RemoveFolderAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken); - - var remaining = new List(); - await foreach (var item in service.ListObjectsAsync(InfrastructureTestFixture.S3Bucket, prefix, TestContext.Current.CancellationToken)) - remaining.Add(item.Key); - Assert.Empty(remaining); - } - - [Fact] - public async Task GetUploadUrlAsync_PresignedPut_CanActuallyUploadTheObject() - { - var key = $"tests/{Guid.NewGuid():N}/presigned.txt"; - - var uploadUrl = await service.GetUploadUrlAsync( - InfrastructureTestFixture.S3Bucket, key, "text/plain", TimeSpan.FromMinutes(5), TestContext.Current.CancellationToken); - - using var httpClient = new HttpClient(); - using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl) - { - 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); - } + 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 d16e298..155d645 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/StreamTicketServiceTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/StreamTicketServiceTests.cs @@ -5,105 +5,104 @@ using Musify.Infrastructure.Configuration; using Musify.Infrastructure.Services; using Xunit; -namespace Musify.Infrastructure.Tests.Services +namespace Musify.Infrastructure.Tests.Services; + +public sealed class StreamTicketServiceTests : IDisposable { - public sealed class StreamTicketServiceTests : IDisposable + private readonly string privateKeyPath = Path.GetTempFileName(); + private readonly RSA rsa = RSA.Create(2048); + + public StreamTicketServiceTests() => File.WriteAllText(privateKeyPath, rsa.ExportPkcs8PrivateKeyPem()); + + public void Dispose() { - private readonly string privateKeyPath = Path.GetTempFileName(); - private readonly RSA rsa = RSA.Create(2048); + rsa.Dispose(); + File.Delete(privateKeyPath); + } - public StreamTicketServiceTests() => File.WriteAllText(privateKeyPath, rsa.ExportPkcs8PrivateKeyPem()); + private StreamTicketService CreateService(int ticketTtlSeconds = 3600) => new(new StreamTicketConfiguration + { + PrivateKeyPath = privateKeyPath, + Audience = "media-gateway", + Issuer = "musify-webapi", + TicketTtlSeconds = ticketTtlSeconds + }); - public void Dispose() + [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 { - rsa.Dispose(); - File.Delete(privateKeyPath); - } - - private StreamTicketService CreateService(int ticketTtlSeconds = 3600) => new(new StreamTicketConfiguration - { - PrivateKeyPath = privateKeyPath, - Audience = "media-gateway", - Issuer = "musify-webapi", - TicketTtlSeconds = ticketTtlSeconds + ValidIssuer = "musify-webapi", + ValidAudience = "media-gateway", + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + IssuerSigningKey = new RsaSecurityKey(verifyingRsa), }); - [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); - } + 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 1e1f45d..a504aa2 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs @@ -1,105 +1,104 @@ using System.Text.Json; using Amazon.S3; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Musify.Infrastructure.Configuration; using Musify.Infrastructure.Persistence; using Testcontainers.PostgreSql; -using DotNet.Testcontainers.Builders; -using DotNet.Testcontainers.Containers; using Xunit; -namespace Musify.Infrastructure.Tests.TestSupport +namespace Musify.Infrastructure.Tests.TestSupport; + +public sealed class InfrastructureTestFixture : IAsyncLifetime { - public sealed class InfrastructureTestFixture : IAsyncLifetime + public const string S3AccessKey = "musify-test"; + public const string S3SecretKey = "musify-test-secret"; + public const string S3Bucket = "musify-test-bucket"; + + private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine") + .WithDatabase("musify_test") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + + private readonly IContainer seaweedFs = new ContainerBuilder("chrislusf/seaweedfs:latest") + .WithEntrypoint("/bin/sh", "-c") + .WithCommand(BuildSeaweedFsStartupScript()) + .WithPortBinding(9333, assignRandomHostPort: true) + .WithPortBinding(8333, assignRandomHostPort: true) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(request => request.ForPort(9333).ForPath("/cluster/healthz"))) + .Build(); + + public string ConnectionString => postgres.GetConnectionString(); + + public string S3ServiceUrl => $"http://{seaweedFs.Hostname}:{seaweedFs.GetMappedPublicPort(8333)}"; + + public async ValueTask InitializeAsync() { - public const string S3AccessKey = "musify-test"; - public const string S3SecretKey = "musify-test-secret"; - public const string S3Bucket = "musify-test-bucket"; + await Task.WhenAll(postgres.StartAsync(), seaweedFs.StartAsync()); - private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine") - .WithDatabase("musify_test") - .WithUsername("postgres") - .WithPassword("postgres") - .Build(); + await using var database = new Database(new DatabaseConfiguration { ConnectionString = ConnectionString }); + await database.Database.MigrateAsync(); - private readonly IContainer seaweedFs = new ContainerBuilder("chrislusf/seaweedfs:latest") - .WithEntrypoint("/bin/sh", "-c") - .WithCommand(BuildSeaweedFsStartupScript()) - .WithPortBinding(9333, assignRandomHostPort: true) - .WithPortBinding(8333, assignRandomHostPort: true) - .WithWaitStrategy(Wait.ForUnixContainer() - .UntilHttpRequestIsSucceeded(request => request.ForPort(9333).ForPath("/cluster/healthz"))) - .Build(); - - public string ConnectionString => postgres.GetConnectionString(); - - public string S3ServiceUrl => $"http://{seaweedFs.Hostname}:{seaweedFs.GetMappedPublicPort(8333)}"; - - public async ValueTask InitializeAsync() - { - await Task.WhenAll(postgres.StartAsync(), seaweedFs.StartAsync()); - - 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 - { - ServiceURL = S3ServiceUrl, - ForcePathStyle = true, - UseHttp = true, - }); - - private static string BuildSeaweedFsStartupScript() - { - var identity = JsonSerializer.Serialize(new - { - identities = new[] - { - new - { - name = "musify-test", - credentials = new[] { new { accessKey = S3AccessKey, secretKey = S3SecretKey } }, - actions = new[] { "Admin", "Read", "Write" } - } - } - }); - - return $""" - cat > /tmp/s3.conf <<'JSON' - {identity} - JSON - exec weed server -ip.bind=0.0.0.0 -dir=/data -master.volumeSizeLimitMB=1024 -volume.max=0 -filer -s3 -s3.config=/tmp/s3.conf - """; - } + using var s3 = CreateS3Client(); + await s3.PutBucketAsync(S3Bucket); } - [CollectionDefinition(Name)] - public sealed class InfrastructureCollection : ICollectionFixture + public async ValueTask DisposeAsync() { - public const string Name = "Infrastructure"; + 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 + { + ServiceURL = S3ServiceUrl, + ForcePathStyle = true, + UseHttp = true, + }); + + private static string BuildSeaweedFsStartupScript() + { + var identity = JsonSerializer.Serialize(new + { + identities = new[] + { + new + { + name = "musify-test", + credentials = new[] { new { accessKey = S3AccessKey, secretKey = S3SecretKey } }, + actions = new[] { "Admin", "Read", "Write" } + } + } + }); + + return $""" + cat > /tmp/s3.conf <<'JSON' + {identity} + JSON + exec weed server -ip.bind=0.0.0.0 -dir=/data -master.volumeSizeLimitMB=1024 -volume.max=0 -filer -s3 -s3.config=/tmp/s3.conf + """; } } + +[CollectionDefinition(Name)] +public sealed class InfrastructureCollection : ICollectionFixture +{ + 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 c454c9a..18f3946 100644 --- a/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs +++ b/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs @@ -6,160 +6,159 @@ using Musify.StreamingGateway.Authentication; using Musify.StreamingGateway.Configuration; using Xunit; -namespace Musify.StreamingGateway.Tests.Authentication +namespace Musify.StreamingGateway.Tests.Authentication; + +public sealed class TicketValidatorTests : IDisposable { - public sealed class TicketValidatorTests : IDisposable + private const string Issuer = "musify-webapi"; + private const string Audience = "media-gateway"; + + private readonly string publicKeyPath = Path.GetTempFileName(); + private readonly RSA rsa = RSA.Create(2048); + private readonly JsonWebTokenHandler tokenHandler = new(); + + public TicketValidatorTests() => File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem()); + + public void Dispose() { - private const string Issuer = "musify-webapi"; - private const string Audience = "media-gateway"; + rsa.Dispose(); + File.Delete(publicKeyPath); + } - private readonly string publicKeyPath = Path.GetTempFileName(); - private readonly RSA rsa = RSA.Create(2048); - private readonly JsonWebTokenHandler tokenHandler = new(); + private TicketValidator CreateValidator() => new(Options.Create(new StreamTicketValidationOptions + { + PublicKeyPath = publicKeyPath, + Audience = Audience, + Issuer = Issuer + })); - public TicketValidatorTests() => File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem()); + 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(); - public void Dispose() + var descriptor = new SecurityTokenDescriptor { - rsa.Dispose(); - File.Delete(publicKeyPath); - } + 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); + } - private TicketValidator CreateValidator() => new(Options.Create(new StreamTicketValidationOptions + [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 { - PublicKeyPath = publicKeyPath, + Issuer = Issuer, Audience = Audience, - Issuer = Issuer - })); + 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); - private string IssueToken(string prefix = "Tracks/ProcessedAudios/abc/", string issuer = Issuer, string audience = Audience, - DateTime? expires = null, DateTime? notBefore = null, long? maxBytes = null) + var result = await CreateValidator().ValidateTicketAsync(token); + + Assert.False(result.IsValid); + } + + [Fact] + public async Task ValidateTicketAsync_MissingPrefixClaim_ReturnsInvalid() + { + var descriptor = new SecurityTokenDescriptor { - var claims = new Dictionary { ["prefix"] = prefix }; - if (maxBytes != null) - claims["maxBytes"] = maxBytes.Value.ToString(); + Issuer = Issuer, + Audience = Audience, + NotBefore = DateTime.UtcNow.AddMinutes(-1), + Expires = DateTime.UtcNow.AddMinutes(5), + SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256), + }; + var token = tokenHandler.CreateToken(descriptor); - var descriptor = new SecurityTokenDescriptor - { - Issuer = issuer, - Audience = audience, - NotBefore = notBefore ?? DateTime.UtcNow.AddMinutes(-1), - Expires = expires ?? DateTime.UtcNow.AddMinutes(5), - Claims = claims, - SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256), - }; - return tokenHandler.CreateToken(descriptor); - } + var result = await CreateValidator().ValidateTicketAsync(token); - [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); - } + 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 9082339..08c4381 100644 --- a/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs +++ b/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs @@ -9,212 +9,211 @@ using Musify.StreamingGateway.Configuration; using Musify.StreamingGateway.Middleware; using Xunit; -namespace Musify.StreamingGateway.Tests.Middleware +namespace Musify.StreamingGateway.Tests.Middleware; + +public sealed class TicketValidationMiddlewareTests : IDisposable { - public sealed class TicketValidationMiddlewareTests : IDisposable + private const string Prefix = "Tracks/ProcessedAudios/abc"; + + private readonly string publicKeyPath = Path.GetTempFileName(); + private readonly RSA rsa = RSA.Create(2048); + private readonly StreamTicketValidationOptions options; + + public TicketValidationMiddlewareTests() { - private const string Prefix = "Tracks/ProcessedAudios/abc"; - - private readonly string publicKeyPath = Path.GetTempFileName(); - private readonly RSA rsa = RSA.Create(2048); - private readonly StreamTicketValidationOptions options; - - public TicketValidationMiddlewareTests() + File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem()); + options = new StreamTicketValidationOptions { - File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem()); - options = new StreamTicketValidationOptions - { - PublicKeyPath = publicKeyPath, - Audience = "media-gateway", - Issuer = "musify-webapi", - }; - } + PublicKeyPath = publicKeyPath, + Audience = "media-gateway", + Issuer = "musify-webapi", + }; + } - public void Dispose() + 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(); + + return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor { - rsa.Dispose(); - File.Delete(publicKeyPath); - } + 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), + }); + } - private string IssueValidToken(string prefix = Prefix, long? maxBytes = null) - { - var claims = new Dictionary { ["prefix"] = prefix }; - if (maxBytes != null) - claims["maxBytes"] = maxBytes.Value.ToString(); + private (TicketValidationMiddleware Middleware, Func WasNextCalled) CreateMiddleware() + { + var nextCalled = false; + RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; }; + var validator = new TicketValidator(Options.Create(options)); + var middleware = new TicketValidationMiddleware(next, validator, Options.Create(options), NullLogger.Instance); + return (middleware, () => nextCalled); + } - 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), - }); - } + [Fact] + public async Task InvokeAsync_PathOutsideMediaPrefix_PassesThroughWithoutValidatingATicket() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = "/health"; - private (TicketValidationMiddleware Middleware, Func WasNextCalled) CreateMiddleware() - { - var nextCalled = false; - RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; }; - var validator = new TicketValidator(Options.Create(options)); - var middleware = new TicketValidationMiddleware(next, validator, Options.Create(options), NullLogger.Instance); - return (middleware, () => nextCalled); - } + await middleware.InvokeAsync(context); - [Fact] - public async Task InvokeAsync_PathOutsideMediaPrefix_PassesThroughWithoutValidatingATicket() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = "/health"; + Assert.True(wasNextCalled()); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + } - await middleware.InvokeAsync(context); + [Fact] + public async Task InvokeAsync_NoTicket_ReturnsUnauthorized() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; - Assert.True(wasNextCalled()); - Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); - } + await middleware.InvokeAsync(context); - [Fact] - public async Task InvokeAsync_NoTicket_ReturnsUnauthorized() - { - var (middleware, wasNextCalled) = CreateMiddleware(); - var context = new DefaultHttpContext(); - context.Request.Path = $"/media/{Prefix}/audio.m4a"; + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + } - await middleware.InvokeAsync(context); + [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"); - Assert.False(wasNextCalled()); - Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); - } + await middleware.InvokeAsync(context); - [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"); + Assert.True(wasNextCalled()); + Assert.Equal("keep=1", context.Request.QueryString.Value?.TrimStart('?')); + } - await middleware.InvokeAsync(context); + [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(); - Assert.True(wasNextCalled()); - Assert.Equal("keep=1", context.Request.QueryString.Value?.TrimStart('?')); - } + await middleware.InvokeAsync(context); - [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(); + Assert.True(wasNextCalled()); + } - await middleware.InvokeAsync(context); + [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()}"); - Assert.True(wasNextCalled()); - } + await middleware.InvokeAsync(context); - [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()}"); + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode); + } - await middleware.InvokeAsync(context); + [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()}"); - Assert.False(wasNextCalled()); - Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode); - } + await middleware.InvokeAsync(context); - [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()}"); + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); + } - await middleware.InvokeAsync(context); + [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()}"); - Assert.False(wasNextCalled()); - Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); - } + await middleware.InvokeAsync(context); - [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()}"); + Assert.True(wasNextCalled()); + } - await middleware.InvokeAsync(context); + [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)}"); - Assert.True(wasNextCalled()); - } + await middleware.InvokeAsync(context); - [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)}"); + Assert.True(wasNextCalled()); + Assert.Equal("bytes=0-479999", context.Request.Headers.Range.ToString()); + } - await middleware.InvokeAsync(context); + [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-"; - Assert.True(wasNextCalled()); - Assert.Equal("bytes=0-479999", context.Request.Headers.Range.ToString()); - } + await middleware.InvokeAsync(context); - [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-"; + Assert.True(wasNextCalled()); + Assert.Equal("bytes=1000-479999", context.Request.Headers.Range.ToString()); + } - await middleware.InvokeAsync(context); + [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-"; - Assert.True(wasNextCalled()); - Assert.Equal("bytes=1000-479999", context.Request.Headers.Range.ToString()); - } + await middleware.InvokeAsync(context); - [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-"; + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, context.Response.StatusCode); + } - await middleware.InvokeAsync(context); + [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"; - Assert.False(wasNextCalled()); - Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, context.Response.StatusCode); - } + await middleware.InvokeAsync(context); - [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); - - Assert.True(wasNextCalled()); - Assert.Equal("bytes=1000-2000", context.Request.Headers.Range.ToString()); - } + Assert.True(wasNextCalled()); + Assert.Equal("bytes=1000-2000", context.Request.Headers.Range.ToString()); } }